@rokkit/chart 1.0.0-next.148 → 1.0.0-next.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,35 +8,37 @@
8
8
  */
9
9
  export function extractFrames(data: Object[], timeField: string): Map<unknown, Object[]>;
10
10
  /**
11
- * Ensures all (x, color) combinations exist in the frame data.
12
- * Missing combinations are filled with y=0 so the animation
13
- * starts/ends smoothly without bars jumping in from nowhere.
11
+ * Ensures all frame values (byField) appear for every (x, color?) combination.
12
+ * Uses dataset alignBy to fill missing frame-value combos with y=0 so bars
13
+ * animate smoothly rather than disappearing between frames.
14
14
  *
15
- * @param {Object[]} frameData - rows for a single frame
16
- * @param {{ x: string, y: string, color?: string }} channels
17
- * @param {unknown[]} allXValues - all x values across all frames
18
- * @param {unknown[] | null} allColorValues - all color values across frames (null if no color)
15
+ * Call after pre-aggregation. The result can be split directly by extractFrames
16
+ * with no further per-frame normalization needed.
17
+ *
18
+ * @param {Object[]} data - pre-aggregated rows, one per (x, color?, byField)
19
+ * @param {{ x?: string, y: string, color?: string }} channels
20
+ * @param {string} byField - the frame field (e.g. 'year')
19
21
  * @returns {Object[]}
20
22
  */
21
- export function normalizeFrame(frameData: Object[], channels: {
22
- x: string;
23
+ export function completeFrames(data: Object[], channels: {
24
+ x?: string;
23
25
  y: string;
24
26
  color?: string;
25
- }, allXValues: unknown[], allColorValues: unknown[] | null): Object[];
27
+ }, byField: string): Object[];
26
28
  /**
27
- * Computes static x/y domains across all frames combined.
28
- * These domains stay constant throughout the animation so bars
29
- * can be compared across frames by absolute height.
29
+ * Computes static x/y domains from the full (pre-split) data array.
30
+ * These domains stay constant throughout the animation so values are
31
+ * always comparable across frames.
30
32
  *
31
- * NOTE: y domain is pinned to [0, max] — assumes bar chart semantics where
32
- * the baseline is always 0. If used with scatter or line charts where y can
33
- * be negative, pass an explicit `yDomain` override instead.
33
+ * NOTE: y domain is pinned to [0, max] — assumes bar chart semantics.
34
+ * Pass an explicit yDomain override for scatter/line charts where y can
35
+ * be negative.
34
36
  *
35
- * @param {Map<unknown, Object[]>} frames
37
+ * @param {Object[]} data - full dataset (before frame extraction)
36
38
  * @param {{ x: string, y: string }} channels
37
39
  * @returns {{ xDomain: unknown[], yDomain: [number, number] }}
38
40
  */
39
- export function computeStaticDomains(frames: Map<unknown, Object[]>, channels: {
41
+ export function computeStaticDomains(data: Object[], channels: {
40
42
  x: string;
41
43
  y: string;
42
44
  }): {
@@ -1,5 +1,5 @@
1
- export function inferFieldType(data: any, field: any): "band" | "continuous";
2
- export function inferOrientation(xType: any, yType: any): "vertical" | "horizontal" | "none";
1
+ export function inferFieldType(data: any, field: any): "continuous" | "band";
2
+ export function inferOrientation(xType: any, yType: any): "none" | "vertical" | "horizontal";
3
3
  export function buildUnifiedXScale(datasets: any, field: any, width: any, opts?: {}): any;
4
4
  export function buildUnifiedYScale(datasets: any, field: any, height: any, opts?: {}): any;
5
5
  export function inferColorScaleType(data: any, field: any, spec?: {}): any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/chart",
3
- "version": "1.0.0-next.148",
3
+ "version": "1.0.0-next.150",
4
4
  "type": "module",
5
5
  "description": "Data-driven chart components",
6
6
  "repository": {
@@ -1,6 +1,7 @@
1
1
  <script>
2
2
  import { onMount, onDestroy } from 'svelte'
3
- import { extractFrames, normalizeFrame, computeStaticDomains } from './lib/plot/frames.js'
3
+ import { extractFrames, completeFrames, computeStaticDomains } from './lib/plot/frames.js'
4
+ import { applyGeomStat } from './lib/plot/stat.js'
4
5
  import Timeline from './Plot/Timeline.svelte'
5
6
  import PlotChart from './Plot.svelte'
6
7
 
@@ -37,16 +38,37 @@
37
38
  children
38
39
  } = $props()
39
40
 
40
- // Extract and normalize frames
41
- const rawFrames = $derived(extractFrames(data, animate.by))
41
+ // Pre-aggregate and complete frames when any geom has a non-identity stat:
42
+ // 1. applyGeomStat: aggregate data by (x, color?, by) → one row per combination
43
+ // 2. completeFrames: alignBy(by) ensures all frame values appear for every (x, color?)
44
+ // group, filling missing rows with y=0 so bars animate smoothly
45
+ // Geoms are returned with stat: 'identity' so PlotChart renders the pre-aggregated values as-is.
46
+ const prepared = $derived.by(() => {
47
+ const firstNonIdentity = geoms.find((g) => g.stat && g.stat !== 'identity')
48
+ if (!firstNonIdentity) return { data, geoms }
49
+
50
+ const aggChannels = { y }
51
+ if (x) aggChannels.x = x
52
+ if (color) aggChannels.color = color
53
+ aggChannels.frame = animate.by // 'frame' is not a value channel, so it becomes a group-by
54
+
55
+ const aggregated = applyGeomStat(
56
+ data,
57
+ { stat: firstNonIdentity.stat, channels: aggChannels },
58
+ helpers
59
+ )
60
+ const completeData = completeFrames(aggregated, { x, y, color }, animate.by)
61
+
62
+ return { data: completeData, geoms: geoms.map((g) => ({ ...g, stat: 'identity' })) }
63
+ })
64
+
65
+ // Extract frames and compute stable domains from the full prepared dataset
66
+ const rawFrames = $derived(extractFrames(prepared.data, animate.by))
42
67
  const frameKeys = $derived([...rawFrames.keys()])
43
68
 
44
69
  const channels = $derived({ x, y, color })
45
- const allXValues = $derived(x ? [...new Set(data.map((d) => d[x]))] : [])
46
- const allColorValues = $derived(color ? [...new Set(data.map((d) => d[color]))] : null)
47
-
48
70
  const staticDomains = $derived(
49
- x && y ? computeStaticDomains(rawFrames, channels) : { xDomain: undefined, yDomain: undefined }
71
+ x && y ? computeStaticDomains(prepared.data, channels) : { xDomain: undefined, yDomain: undefined }
50
72
  )
51
73
 
52
74
  // Playback state
@@ -54,12 +76,10 @@
54
76
  let playing = $state(false)
55
77
  let speed = $state(1)
56
78
 
57
- // Current frame data (normalized missing combos filled with 0)
79
+ // Current frame data — already complete (all x/color combos present)
58
80
  const currentFrameData = $derived.by(() => {
59
81
  const key = frameKeys[currentIndex]
60
- const raw = rawFrames.get(key) ?? []
61
- if (!x || !y) return raw
62
- return normalizeFrame(raw, { x, y, color }, allXValues, allColorValues)
82
+ return rawFrames.get(key) ?? []
63
83
  })
64
84
 
65
85
  // Reduced motion preference
@@ -154,7 +174,7 @@
154
174
  const frameSpec = $derived({
155
175
  data: currentFrameData,
156
176
  x, y, color,
157
- geoms,
177
+ geoms: prepared.geoms,
158
178
  xDomain: staticDomains.xDomain,
159
179
  yDomain: staticDomains.yDomain
160
180
  })
@@ -1,4 +1,5 @@
1
1
  import { extent } from 'd3-array'
2
+ import { dataset } from '@rokkit/data'
2
3
 
3
4
  /**
4
5
  * Extracts animation frames from data, keyed by time field value.
@@ -19,72 +20,61 @@ export function extractFrames(data, timeField) {
19
20
  }
20
21
 
21
22
  /**
22
- * Ensures all (x, color) combinations exist in the frame data.
23
- * Missing combinations are filled with y=0 so the animation
24
- * starts/ends smoothly without bars jumping in from nowhere.
23
+ * Ensures all frame values (byField) appear for every (x, color?) combination.
24
+ * Uses dataset alignBy to fill missing frame-value combos with y=0 so bars
25
+ * animate smoothly rather than disappearing between frames.
25
26
  *
26
- * @param {Object[]} frameData - rows for a single frame
27
- * @param {{ x: string, y: string, color?: string }} channels
28
- * @param {unknown[]} allXValues - all x values across all frames
29
- * @param {unknown[] | null} allColorValues - all color values across frames (null if no color)
27
+ * Call after pre-aggregation. The result can be split directly by extractFrames
28
+ * with no further per-frame normalization needed.
29
+ *
30
+ * @param {Object[]} data - pre-aggregated rows, one per (x, color?, byField)
31
+ * @param {{ x?: string, y: string, color?: string }} channels
32
+ * @param {string} byField - the frame field (e.g. 'year')
30
33
  * @returns {Object[]}
31
34
  */
32
- export function normalizeFrame(frameData, channels, allXValues, allColorValues) {
35
+ export function completeFrames(data, channels, byField) {
33
36
  const { x: xf, y: yf, color: cf } = channels
37
+ const groupFields = [xf, ...(cf ? [cf] : [])].filter(Boolean)
34
38
 
35
- if (cf && !allColorValues?.length) {
36
- throw new Error('normalizeFrame: allColorValues must be provided when color channel is set')
37
- }
38
-
39
- // Build lookup of existing (x, color?) keys
40
- const existing = new Set(
41
- frameData.map((d) => (cf ? `${d[xf]}::${d[cf]}` : String(d[xf])))
42
- )
43
-
44
- const filled = [...frameData]
39
+ if (groupFields.length === 0) return data
45
40
 
46
- const colorValues = cf && allColorValues ? allColorValues : [null]
41
+ const nested = dataset(data)
42
+ .groupBy(...groupFields)
43
+ .alignBy(byField)
44
+ .usingTemplate({ [yf]: 0 })
45
+ .rollup()
46
+ .select()
47
47
 
48
- for (const xVal of allXValues) {
49
- for (const colorVal of colorValues) {
50
- const key = cf ? `${xVal}::${colorVal}` : String(xVal)
51
- if (!existing.has(key)) {
52
- const row = { [xf]: xVal, [yf]: 0 }
53
- if (cf && colorVal !== null) row[cf] = colorVal
54
- filled.push(row)
55
- }
56
- }
57
- }
58
-
59
- return filled
48
+ return nested.flatMap((row) => {
49
+ const groupKey = groupFields.reduce((acc, f) => ({ ...acc, [f]: row[f] }), {})
50
+ // strip the actual_flag marker added by alignBy
51
+ return row.children.map(({ actual_flag: _af, ...child }) => ({ ...groupKey, ...child }))
52
+ })
60
53
  }
61
54
 
62
55
  /**
63
- * Computes static x/y domains across all frames combined.
64
- * These domains stay constant throughout the animation so bars
65
- * can be compared across frames by absolute height.
56
+ * Computes static x/y domains from the full (pre-split) data array.
57
+ * These domains stay constant throughout the animation so values are
58
+ * always comparable across frames.
66
59
  *
67
- * NOTE: y domain is pinned to [0, max] — assumes bar chart semantics where
68
- * the baseline is always 0. If used with scatter or line charts where y can
69
- * be negative, pass an explicit `yDomain` override instead.
60
+ * NOTE: y domain is pinned to [0, max] — assumes bar chart semantics.
61
+ * Pass an explicit yDomain override for scatter/line charts where y can
62
+ * be negative.
70
63
  *
71
- * @param {Map<unknown, Object[]>} frames
64
+ * @param {Object[]} data - full dataset (before frame extraction)
72
65
  * @param {{ x: string, y: string }} channels
73
66
  * @returns {{ xDomain: unknown[], yDomain: [number, number] }}
74
67
  */
75
- export function computeStaticDomains(frames, channels) {
68
+ export function computeStaticDomains(data, channels) {
76
69
  const { x: xf, y: yf } = channels
77
- const allData = [...frames.values()].flat()
78
-
79
- const sampleX = allData[0]?.[xf]
80
- const xIsCategorical = typeof sampleX === 'string'
81
70
 
82
- const xDomain = xIsCategorical
83
- ? [...new Set(allData.map((d) => d[xf]))]
84
- : extent(allData, (d) => Number(d[xf]))
71
+ const sampleX = data[0]?.[xf]
72
+ const xDomain = typeof sampleX === 'string'
73
+ ? [...new Set(data.map((d) => d[xf]))]
74
+ : extent(data, (d) => Number(d[xf]))
85
75
 
86
- const [, yMax] = extent(allData, (d) => Number(d[yf]))
87
- const yDomain = [0, yMax ?? 0] // pin to 0 (bar chart default)
76
+ const [, yMax] = extent(data, (d) => Number(d[yf]))
77
+ const yDomain = [0, yMax ?? 0]
88
78
 
89
79
  return { xDomain, yDomain }
90
80
  }