@evidence-dev/component-utilities
Advanced tools
Comparing version 0.0.0-e1b03a15 to 0.0.0-e22a6592
# @evidence-dev/component-utilities | ||
## 1.1.2 | ||
### Patch Changes | ||
- 4944f21c: getCompletedData() fills all x values for categorical series | ||
- 287126fe: Ensure that numeric and date x-axis series are sorted | ||
## 1.1.1 | ||
@@ -4,0 +11,0 @@ |
{ | ||
"name": "@evidence-dev/component-utilities", | ||
"version": "0.0.0-e1b03a15", | ||
"version": "0.0.0-e22a6592", | ||
"description": "", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
@@ -5,2 +5,11 @@ import { registerTheme, init } from 'echarts'; | ||
/** | ||
* @typedef {import("echarts").EChartsOption & { | ||
* dispatch?: ReturnType<typeof import("svelte").createEventDispatcher>; | ||
* showAllXAxisLabels?: boolean; | ||
* } | ||
* } ActionParams | ||
*/ | ||
/** @type {import("svelte/action").Action<HTMLElement, ActionParams>} */ | ||
export default (node, option) => { | ||
@@ -428,13 +437,2 @@ registerTheme('evidence-light', { | ||
// If the x-axis of a series is numeric, or a date; ensure that it is in order | ||
option.series = option.series.map((s) => { | ||
if (typeof s.data?.[0][0] === 'number') { | ||
s.data = s.data.sort((a, b) => a[0] - b[0]); | ||
} else if (s.data?.[0][0] instanceof Date) { | ||
s.data = s.data.sort((a, b) => a[0].getTime() - b[0]?.getTime() ?? 0); | ||
} | ||
return s; | ||
}); | ||
chart.setOption(option); | ||
@@ -449,3 +447,3 @@ | ||
const containerElement = document.querySelector('div.content > article'); | ||
const resizeChart = debounce(() => { | ||
const onWindowResize = debounce(() => { | ||
chart.resize({ | ||
@@ -456,16 +454,46 @@ animation: { | ||
}); | ||
updateLabelWidths(); | ||
}, 100); | ||
const updateLabelWidths = () => { | ||
// Make sure we operate on an up-to-date options object | ||
/** @type {import("echarts").EChartsOption} */ | ||
const prevOption = chart.getOption(); | ||
if (!prevOption) return; | ||
// If the options object includes showing all x axis labels | ||
// Note: this isn't a standard option, but right now this is the easiest way to pass something to the action. | ||
// We don't want to have multiple resize observers if we can avoid it, and this is all due for a cleanup anyways | ||
if (prevOption.showAllXAxisLabels) { | ||
// Get all the possible x values | ||
const distinctXValues = new Set(prevOption.series.flatMap((s) => s.data?.map((d) => d[0]))); | ||
const modConst = 4 / 5; | ||
const clientWidth = node?.clientWidth ?? 0; | ||
/** @type {import("echarts").EChartsOption} */ | ||
const newOption = { | ||
xAxis: { | ||
axisLabel: { | ||
interval: 0, | ||
overflow: 'truncate', | ||
width: (clientWidth * modConst) / distinctXValues.size | ||
} | ||
} | ||
}; | ||
chart.setOption(newOption); | ||
} | ||
}; | ||
if (window.ResizeObserver && containerElement) { | ||
// TODO: This was originally added to combat a bug here: https://github.com/evidence-dev/evidence/pull/450 | ||
// Another solution is required. Something like lodash debounce might be an easy win to solve this. | ||
resizeObserver = new ResizeObserver(resizeChart); | ||
resizeObserver = new ResizeObserver(onWindowResize); | ||
resizeObserver.observe(containerElement); | ||
} else { | ||
window.addEventListener('resize', resizeChart); | ||
window.addEventListener('resize', onWindowResize); | ||
} | ||
onWindowResize(); | ||
return { | ||
update(option) { | ||
chart.setOption(option, true, true); | ||
updateLabelWidths(); | ||
}, | ||
@@ -476,3 +504,3 @@ destroy() { | ||
} else { | ||
window.removeEventListener('resize', resizeChart); | ||
window.removeEventListener('resize', onWindowResize); | ||
} | ||
@@ -479,0 +507,0 @@ chart.dispose(); |
@@ -13,3 +13,3 @@ import ssf from 'ssf'; | ||
export const getCustomFormats = () => { | ||
return getContext(CUSTOM_FORMATTING_SETTINGS_CONTEXT_KEY).getCustomFormats() || []; | ||
return getContext(CUSTOM_FORMATTING_SETTINGS_CONTEXT_KEY)?.getCustomFormats() || []; | ||
}; | ||
@@ -16,0 +16,0 @@ |
@@ -28,7 +28,44 @@ import { tidy, complete, mutate } from '@tidyjs/tidy'; | ||
// Ensures that all permutations of this map exist in the output | ||
// e.g. can include series and x values to ensure that all series have all x values | ||
const expandKeys = {}; | ||
const xIsDate = data[0]?.[x] instanceof Date; | ||
/** @type {Array<number | string>} */ | ||
let xDistinct; | ||
const exampleX = data[0]?.[x]; | ||
switch (typeof exampleX) { | ||
case 'object': | ||
// If x is not a date; this shouldn't be hit, abort! | ||
if (!(exampleX instanceof Date)) { | ||
throw new Error('Unexpected object property, expected string, date, or number'); | ||
} | ||
// Map dates to numeric values | ||
xDistinct = getDistinctValues( | ||
data.map((d) => ({ [x]: d[x].getTime() })), | ||
x | ||
); | ||
// We don't fillX here because date numbers are very large, so a small interval would create a _massive_ array | ||
break; | ||
case 'number': | ||
// Numbers are the most straightforward | ||
xDistinct = getDistinctValues(data, x); | ||
if (fillX) { | ||
// Attempt to derive the interval between X values and interpolate missing values in that set (within the bounds of min/max) | ||
const interval = findInterval(xDistinct); | ||
expandKeys[x] = vectorSeq(xDistinct, interval); | ||
} | ||
break; | ||
case 'string': | ||
xDistinct = getDistinctValues(data, x); | ||
expandKeys[x] = xDistinct; | ||
break; | ||
} | ||
const output = []; | ||
for (const value of Object.values(groups)) { | ||
let xIsDate = value[0]?.[x] instanceof Date; | ||
const nullySpec = { series: null }; | ||
const nullySpec = series ? { [series]: null } : {}; | ||
if (nullsZero) { | ||
@@ -41,20 +78,2 @@ nullySpec[y] = 0; | ||
const expandKeys = {}; | ||
if (fillX) { | ||
/** @type {Array<number>} */ | ||
let xDistinct; | ||
if (xIsDate) | ||
xDistinct = getDistinctValues( | ||
value.map((d) => ({ [x]: d[x].getTime() })), | ||
x | ||
); | ||
else xDistinct = getDistinctValues(value, x); | ||
/** @type {number} */ | ||
let interval = findInterval(xDistinct); | ||
// Array of all possible x values | ||
expandKeys[x] = vectorSeq(xDistinct, interval); | ||
} | ||
if (series) { | ||
@@ -65,6 +84,5 @@ expandKeys[series] = series; | ||
const tidyFuncs = []; | ||
if (Object.keys(expandKeys).length === 0) { | ||
// empty object, no special configuration | ||
tidyFuncs.push(complete([x], nullySpec)); | ||
// empty object, no special configuration | ||
} else { | ||
@@ -74,2 +92,3 @@ tidyFuncs.push(complete(expandKeys, nullySpec)); | ||
if (xIsDate) { | ||
// Ensure that x is actually a date | ||
tidyFuncs.push( | ||
@@ -81,5 +100,6 @@ mutate({ | ||
} | ||
output.push(...tidy(value, ...tidyFuncs)); | ||
output.push(tidy(value, ...tidyFuncs)); | ||
} | ||
return output; | ||
return output.flat(); | ||
} |
@@ -9,2 +9,16 @@ import { faker } from '@faker-js/faker'; | ||
/** | ||
* @typedef {Object} GenSeriesKeyOpts | ||
* @property {string} [x] | ||
* @property {string} [y] | ||
* @property {MockSeries} [series] | ||
*/ | ||
/** | ||
* @typedef {Object} GenSeriesResult | ||
* @property { { series: MockSeries, value: number, time: number }[] } data | ||
* @property { Record<MockSeries, { interval: number }> } series | ||
* @property { GenSeriesKeyOpts } keys | ||
*/ | ||
/** | ||
* @typedef {Object} GenSeriesOpts | ||
@@ -14,23 +28,34 @@ * @property {boolean} xHasGaps Determines if the x axis will have all expected values | ||
* @property {boolean} seriesAlwaysExists | ||
* @property {number} [seriesLen] Max length of each series | ||
* @property {number} [minSeriesLen] Min length of each series | ||
* @property {number} [maxSeriesLen] Max length of each series | ||
* @property {number} [minSeriesCount] Min number of series | ||
* @property {number} [maxSeriesCount] Max number of series | ||
* @property {number} [minInterval] Min interval between x values (e.g. interval of 2 is 0,2,4) | ||
* @property {number} [maxInterval] Max interval between x values (e.g. interval of 2 is 0,2,4) | ||
* @property {number} [maxOffset] Max offset for initial x value (e.g. interval of 2, offset of 1 is 1,3,5) | ||
* @property {'number' | 'date'} [xType] determines the type of the x axis | ||
* @property {'number' | 'date' | 'categories'} [xType] determines the type of the x axis | ||
* @property { GenSeriesKeyOpts} [keys] Allows changing the structure of the output | ||
*/ | ||
/** | ||
* @param {} | ||
* @returns { { data: { series: MockSeries, value: number, time: number }[], series: Record<MockSeries, { interval: number }> } } | ||
* @param {GenSeriesOpts} | ||
* @returns {GenSeriesResult} | ||
*/ | ||
export const genSeries = ({ | ||
const genNumericSeries = ({ | ||
xHasGaps = false, | ||
yHasNulls = false, | ||
seriesAlwaysExists = true, | ||
maxSeriesLen = 20, | ||
minSeriesLen = 10, | ||
minSeriesLen = 2, | ||
maxSeriesLen = 10, | ||
minInterval = 1, | ||
maxInterval = 5, | ||
minSeriesCount = 2, | ||
maxSeriesCount = 5, | ||
minInterval = 1, | ||
maxInterval = 20, | ||
maxOffset = 100, | ||
xType = 'number' | ||
maxOffset = 10, | ||
xType = 'number', | ||
keys = { | ||
x: 'time', | ||
y: 'value', | ||
series: 'series' | ||
} | ||
} = {}) => { | ||
@@ -43,4 +68,4 @@ const data = []; | ||
faker.number.int({ | ||
min: 2, | ||
max: maxSeriesCount | ||
min: minSeriesCount, | ||
max: maxSeriesCount < minSeriesCount ? minSeriesCount : maxSeriesCount | ||
}) | ||
@@ -58,2 +83,3 @@ ) | ||
); | ||
for (const [seriesName, d] of Object.entries(series)) { | ||
@@ -68,5 +94,5 @@ const initialValue = xType === 'number' ? d.offset : new Date(); | ||
data.push({ | ||
series: seriesName, | ||
value: faker.number.float({ min: -1000, max: 1000 }), | ||
time: genTime(i) | ||
[keys.series]: seriesName, | ||
[keys.y]: faker.number.float({ min: -1000, max: 1000 }), | ||
[keys.x]: genTime(i) | ||
}); | ||
@@ -101,5 +127,5 @@ } | ||
data.push({ | ||
series: null, | ||
value: faker.number.float({ min: -1000, max: 1000 }), | ||
time: genTime(i) | ||
[keys.series]: null, | ||
[keys.y]: faker.number.float({ min: -1000, max: 1000 }), | ||
[keys.x]: genTime(i) | ||
}); | ||
@@ -121,4 +147,66 @@ } | ||
series, | ||
data | ||
data, | ||
keys | ||
}; | ||
}; | ||
/** | ||
* @param {GenSeriesOpts} | ||
* @returns {GenSeriesResult} | ||
*/ | ||
const getCatagoricalSeries = ({ | ||
minSeriesLen = 5, | ||
maxSeriesLen = 50, | ||
minSeriesCount = 2, | ||
maxSeriesCount = 10, | ||
keys = { | ||
x: 'category', | ||
y: 'value', | ||
series: 'series' | ||
} | ||
} = {}) => { | ||
const seriesLength = faker.number.int({ min: minSeriesLen, max: maxSeriesLen }); | ||
const seriesCount = faker.number.int({ min: minSeriesCount, max: maxSeriesCount }); | ||
const categories = new Array(seriesLength).fill(null).map(() => faker.location.streetAddress()); | ||
const series = new Array(seriesCount).fill(null).map(() => faker.company.name()); | ||
const data = series.reduce((a, v) => { | ||
for (const category of categories) { | ||
a.push({ | ||
[keys.series]: v, | ||
[keys.x]: category, | ||
[keys.y]: faker.number.int({ min: 0, max: 10000 }) | ||
}); | ||
} | ||
return a; | ||
}, []); | ||
return { | ||
series: Object.fromEntries( | ||
series.map((seriesName) => [seriesName, { len: seriesLength, offset: 0, interval: 1 }]) | ||
), | ||
data, | ||
keys | ||
}; | ||
}; | ||
/** | ||
* @param {GenSeriesOpts} cfg | ||
* @returns {GenSeriesResult} | ||
*/ | ||
export const genSeries = (cfg = {}) => { | ||
let v; | ||
switch (cfg.xType) { | ||
case 'date': | ||
case 'number': | ||
default: | ||
v = genNumericSeries(cfg); | ||
break; | ||
case 'category': | ||
v = getCatagoricalSeries(cfg); | ||
break; | ||
} | ||
return v; | ||
}; |
import { describe, it, expect } from 'vitest'; | ||
import getCompletedData from '../getCompletedData'; | ||
import { genSeries } from './getCompletedData.fixture.js'; | ||
import { genSeries } from './getCompletedData.fixture'; | ||
import { MissingYCase } from './getCompletedData.fixture.manual'; | ||
const sortFunc = (a, b) => { | ||
const deltaSeries = a.series?.charCodeAt(0) ?? -1 - b.series?.charCodeAt(0) ?? -1; | ||
if (deltaSeries !== 0) return deltaSeries; | ||
const deltaTime = a.time - b.time; | ||
if (deltaTime !== 0) return deltaTime; | ||
const deltaValue = a.value - b.value; | ||
if (deltaValue !== 0) return deltaValue; | ||
return 0; | ||
/** | ||
* @param {string} a | ||
* @param {string} b | ||
*/ | ||
const stringSortFunc = (a, b) => { | ||
// Iterate through the strings | ||
for (let i = 0; i < a.length && i < b.length; i++) { | ||
const diff = a.charCodeAt(i) - b.charCodeAt(i); | ||
if (diff !== 0) return diff; | ||
} | ||
return a.length - b.length; // the longer string wins | ||
}; | ||
const series = []; | ||
let series = []; | ||
const simple = false; | ||
const fixturePermutations = { | ||
xHasGaps: simple ? [true, false] : [true, false], | ||
yHasNulls: simple ? [true, false] : [true, false], | ||
seriesAlwaysExists: simple ? [true, false] : [true, false], | ||
xType: simple ? ['category'] : ['date', 'number', 'category'], | ||
keys: simple | ||
? [undefined, { x: 'someX', y: 'someY', series: 'someSeries' }] | ||
: [undefined, { x: 'someX', y: 'someY', series: 'someSeries' }] | ||
}; | ||
/* | ||
@@ -23,12 +39,11 @@ This is responsible for generating a variety of scenarios that the function may encounter | ||
*/ | ||
for (const xHasGaps of [true, false]) { | ||
for (const yHasNulls of [true, false]) { | ||
for (const seriesAlwaysExists of [true, false]) { | ||
for (const xType of ['date', 'number']) | ||
series.push({ | ||
xHasGaps, | ||
yHasNulls, | ||
seriesAlwaysExists, | ||
xType, | ||
data: genSeries({ | ||
for (const xHasGaps of fixturePermutations.xHasGaps) { | ||
for (const yHasNulls of fixturePermutations.yHasNulls) | ||
for (const seriesAlwaysExists of fixturePermutations.seriesAlwaysExists) | ||
for (const xType of fixturePermutations.xType) | ||
for (const keys of fixturePermutations.keys) | ||
series.push({ | ||
description: `(automatic) xType = "${xType}", xHasGaps = ${xHasGaps}, yHasNulls = ${yHasNulls}, seriesAlwaysExists = ${seriesAlwaysExists}, keys = "${JSON.stringify( | ||
keys | ||
)}"`, | ||
xHasGaps, | ||
@@ -38,22 +53,44 @@ yHasNulls, | ||
xType, | ||
seriesLen: 3, | ||
seriesCount: 2, | ||
maxInterval: 1, | ||
maxOffset: 0 | ||
}) | ||
}); | ||
} | ||
} | ||
keys, | ||
manual: false, | ||
data: genSeries({ | ||
xHasGaps, | ||
yHasNulls, | ||
seriesAlwaysExists, | ||
xType, | ||
keys, | ||
minSeriesLen: 2, | ||
maxSeriesLen: 2, | ||
maxSeriesCount: 2, | ||
maxInterval: 1, | ||
maxOffset: 0 | ||
}) | ||
}); | ||
} | ||
series.push({ | ||
description: '(manual) Manual gap values injected', | ||
data: MissingYCase | ||
}); | ||
// Garlic Naan | Goa Chicken | Chicken Curry | ||
/** | ||
* @typedef {Object} SeriesFixture | ||
* @property {import("./getCompletedData.fixture.js").GenSeriesResult} data | ||
* @property { 'date' | 'number' } xType | ||
* @property {boolean} seriesAlwaysExists | ||
* @property {boolean} yHasNulls | ||
* @property {boolean} xHasgaps | ||
* @property {boolean} manual | ||
*/ | ||
describe('getCompletedData', () => { | ||
describe.each(series)( | ||
'xHasGaps = $xHasGaps, yHasNulls = $yHasNulls, seriesAlwaysExists = $seriesAlwaysExists, xType = "$xType"', | ||
'$description', | ||
/** | ||
* @param {ReturnType<typeof genSeries>} opts | ||
* @param {SeriesFixture} opts | ||
*/ | ||
(opts) => { | ||
const { data, series } = opts.data; | ||
const { data, keys } = opts.data; | ||
it('returns no duplicate rows', () => { | ||
const result = getCompletedData(data, 'time', 'value', 'series', false, false); | ||
const result = getCompletedData(data, keys.x, keys.y, keys.series, false, false); | ||
for (const row of result) { | ||
@@ -63,3 +100,5 @@ expect( | ||
(row2) => | ||
row.time === row2.time && row.value === row2.value && row.series === row2.series | ||
row[keys.x] === row2[keys.x] && | ||
row[keys.y] === row2[keys.y] && | ||
row[keys.series] === row2[keys.series] | ||
).length | ||
@@ -71,8 +110,11 @@ ).toBe(1); | ||
it('replaces nulls with zero if nullsZero is set', () => { | ||
const result = getCompletedData(data, 'time', 'value', 'series', true, true); | ||
const result = getCompletedData(data, keys.x, keys.y, keys.series, true, true); | ||
for (const row of data.filter((r) => r.value === null)) { | ||
for (const row of data.filter((r) => r[keys.y] === null)) { | ||
expect( | ||
result.find((r) => r.series === row.series && r.time.toString() === row.time.toString()) | ||
?.value | ||
result.find( | ||
(r) => | ||
r[keys.series] === row[keys.series] && | ||
r[keys.x].toString() === row[keys.x].toString() | ||
)?.[keys.y] | ||
).toBeCloseTo(0); | ||
@@ -83,28 +125,60 @@ } | ||
it('does not fill x-axis values if fillX is not set', () => { | ||
const result = getCompletedData(data, 'time', 'value', 'series', false, false); | ||
const result = getCompletedData(data, keys.x, keys.y, keys.series, false, false); | ||
// Expect specific behavior here based on your function's logic | ||
expect(result.every((r) => data.some((d) => r.time === d.time && r.series === d.series))); | ||
expect( | ||
result.every((r) => | ||
data.some((d) => r[keys.x] === d[keys.x] && r[keys.series] === d[keys.series]) | ||
) | ||
); | ||
}); | ||
it('returns the original data if series is not defined', () => { | ||
const result = getCompletedData(data, 'time', 'value', undefined, false, false); | ||
it('returns identical columns to the original data', () => { | ||
const result = getCompletedData(data, keys.x, keys.y, keys.series, false, false); | ||
const r = result.sort(sortFunc); | ||
const d = data.sort(sortFunc); | ||
const r = Object.keys(result[0]).sort(stringSortFunc); | ||
const d = Object.keys(data[0]).sort(stringSortFunc); | ||
expect(r).toEqual(d); | ||
}); | ||
// This condition is only applicable to non-date series | ||
if (opts.xType !== 'date') | ||
it('contains series each with identical lengths', () => { | ||
const result = getCompletedData(data, keys.x, keys.y, keys.series, false, true); | ||
/** @type {any[][]} */ | ||
let groupedSeries = []; | ||
for (const seriesName of Object.keys(opts.data.series)) { | ||
const seriesItems = result.filter((d) => d[keys.series] === seriesName); | ||
groupedSeries.push(seriesItems); | ||
} | ||
expect(groupedSeries[0].length, 'Series must have more than one row').toBeGreaterThan(0); | ||
for (const s of groupedSeries) { | ||
expect(s.length, 'Series lengths must all be equal').toEqual(groupedSeries[0].length); | ||
} | ||
}); | ||
it('returns the original data if series is not defined', () => { | ||
const { x, y } = keys; | ||
const result = getCompletedData(data, x, y, undefined, false, false); | ||
expect(data).toEqual(expect.arrayContaining(result)); | ||
}); | ||
it('fills missing x-axis values with null if fillX is set and not nullsZero', () => { | ||
const result = getCompletedData(data, 'time', 'value', 'series', false, true); | ||
const { x, y, series } = keys; | ||
const result = getCompletedData(data, x, y, series, false, true); | ||
// Expect specific behavior here based on your function's logic | ||
for (const seriesName in series) { | ||
for (const row of result.filter((r) => r.series === seriesName)) { | ||
const inputRow = data.find((d) => d.series === row.series && d.time === row.time); | ||
for (const row of result.filter((r) => r[series] === seriesName)) { | ||
const inputRow = data.find((d) => d[series] === row[series] && d[x] === row[x]); | ||
if (inputRow) { | ||
// This row already existed | ||
expect(row.value).toEqual(inputRow.value); | ||
expect(row[y]).toEqual(inputRow[y]); | ||
} else { | ||
// This row was inserted | ||
expect(row.value).toBe(null); | ||
expect(row[y]).toBe(null); | ||
} | ||
@@ -116,13 +190,15 @@ } | ||
it('fills missing x-axis values with zero if fillX and nullsZero are set', () => { | ||
const result = getCompletedData(data, 'time', 'value', 'series', true, true); | ||
const { x, y, series } = keys; | ||
const result = getCompletedData(data, x, y, series, true, true); | ||
// Expect specific behavior here based on your function's logic | ||
for (const seriesName in series) { | ||
for (const row of result.filter((r) => r.series === seriesName)) { | ||
const inputRow = data.find((d) => d.series === row.series && d.time === row.time); | ||
if (inputRow && inputRow.value !== null) { | ||
for (const row of result.filter((r) => r[series] === seriesName)) { | ||
const inputRow = data.find((d) => d[series] === row[series] && d[x] === row[x]); | ||
if (inputRow && inputRow[y] !== null) { | ||
// This row already existed | ||
expect(row.value).toEqual(inputRow.value); | ||
expect(row[y]).toEqual(inputRow[y]); | ||
} else { | ||
// This row was inserted | ||
expect(row.value).toBe(0); | ||
expect(row[y]).toBe(0); | ||
} | ||
@@ -129,0 +205,0 @@ } |
204988
38
7237