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

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.
@@ -9,12 +9,12 @@ export class PlotState {
9
9
  fill: string;
10
10
  stroke: string;
11
11
  }>;
12
- patterns: Map<any, any>;
13
- symbols: Map<any, any>;
12
+ patterns: Map<unknown, string> | SvelteMap<any, any>;
13
+ symbols: Map<unknown, string> | SvelteMap<any, any>;
14
14
  colorField: any;
15
15
  patternField: any;
16
16
  symbolField: any;
17
- geomTypes: Set<any>;
17
+ geomTypes: SvelteSet<any>;
18
18
  xAxisY: any;
19
19
  yAxisX: any;
20
20
  update(config: any): void;
@@ -45,3 +45,5 @@ export class PlotState {
45
45
  clearHovered(): void;
46
46
  #private;
47
47
  }
48
+ import { SvelteMap } from 'svelte/reactivity';
49
+ import { SvelteSet } from 'svelte/reactivity';
@@ -1,15 +1,13 @@
1
- /**
2
- * Creates a reactive cross-filter state object.
3
- *
4
- * Filter values follow the spec type:
5
- * FilterState = Map<string, Set<unknown> | [number, number]>
6
- * - categorical: raw Set of selected values
7
- * - continuous: [min, max] tuple
8
- *
9
- * Exposes a `filters` getter so CrossFilter.svelte can bind to current state.
10
- * Exposes a `version` counter that increments on every mutation, giving
11
- * components a simple reactive signal to watch for filter changes.
12
- *
13
- * @returns {CrossFilter}
14
- */
15
- export function createCrossFilter(): CrossFilter;
1
+ export function createCrossFilter(): {
2
+ /** @readonly reactive Map of current filter state */
3
+ readonly filters: SvelteMap<any, any>;
4
+ /** @readonly increments on every mutation; read inside $effect to react to any filter change */
5
+ readonly version: number;
6
+ isFiltered: (dimension: string) => boolean;
7
+ isDimmed: (dimension: string, value: unknown) => boolean;
8
+ toggleCategorical: (dimension: string, value: unknown) => void;
9
+ setRange: (dimension: string, range: [number, number]) => void;
10
+ clearFilter: (dimension: string) => void;
11
+ clearAll: () => void;
12
+ };
13
+ import { SvelteMap } from 'svelte/reactivity';
@@ -1,31 +1,6 @@
1
- /**
2
- * Groups aesthetic channel mappings by field name, merging aesthetics that
3
- * share the same field into one legend section.
4
- *
5
- * @param {{ fill?: string, color?: string, pattern?: string, symbol?: string }} channels
6
- * `fill` takes precedence over `color` for polygon charts (bars, areas, pie slices).
7
- * @param {Map<unknown, {fill:string, stroke:string}>} colorMap
8
- * @param {Map<unknown, string>} patternMap
9
- * @param {Map<unknown, string>} symbolMap
10
- * @returns {{ field: string, items: { label: string, fill: string|null, stroke: string|null, patternId: string|null, shape: string|null }[] }[]}
11
- */
12
- export function buildLegendGroups(channels: {
13
- fill?: string;
14
- color?: string;
15
- pattern?: string;
16
- symbol?: string;
17
- }, colorMap: Map<unknown, {
18
- fill: string;
19
- stroke: string;
20
- }>, patternMap: Map<unknown, string>, symbolMap: Map<unknown, string>): {
21
- field: string;
22
- items: {
23
- label: string;
24
- fill: string | null;
25
- stroke: string | null;
26
- patternId: string | null;
27
- shape: string | null;
28
- }[];
1
+ export function buildLegendGroups(channels: any, colorMap: any, patternMap: any, symbolMap: any): {
2
+ field: any;
3
+ items: any;
29
4
  }[];
30
5
  export class ChartBrewer {
31
6
  /**
@@ -107,14 +82,8 @@ export class ChartBrewer {
107
82
  key: any;
108
83
  }[];
109
84
  legendGroups: {
110
- field: string;
111
- items: {
112
- label: string;
113
- fill: string | null;
114
- stroke: string | null;
115
- patternId: string | null;
116
- shape: string | null;
117
- }[];
85
+ field: any;
86
+ items: any;
118
87
  }[];
119
88
  get margin(): {
120
89
  top: number;
@@ -11,19 +11,11 @@ export function applyBoxStat(data: Object[], channels: {
11
11
  y?: string;
12
12
  color?: string;
13
13
  }): Object[];
14
- /**
15
- * Aggregates data by one or more grouping fields, reducing the value field
16
- * using the given stat. Accepts a built-in name or a custom function.
17
- *
18
- * @param {Object[]} data
19
- * @param {{ by: string[], value: string, stat: string|Function }} opts
20
- * @returns {Object[]}
21
- */
22
- export function applyAggregate(data: Object[], { by, value, stat }: {
23
- by: string[];
24
- value: string;
25
- stat: string | Function;
26
- }): Object[];
14
+ export function applyAggregate(data: any, { by, value, stat }: {
15
+ by: any;
16
+ value: any;
17
+ stat: any;
18
+ }): any;
27
19
  /**
28
20
  * Built-in reduction functions. Each receives an array of numeric values.
29
21
  * @type {Record<string, (values: number[]) => number>}
@@ -1,8 +1,8 @@
1
1
  export function chart(data: any, aes: any): Chart;
2
2
  declare class Chart {
3
3
  constructor(data: any, opts: any);
4
- width: number;
5
- height: number;
4
+ width: number | undefined;
5
+ height: number | undefined;
6
6
  flipCoords: any;
7
7
  x: any;
8
8
  y: any;
@@ -13,13 +13,10 @@ declare class Chart {
13
13
  color: any;
14
14
  shape: any;
15
15
  padding(value: any): this;
16
- spacing: number;
16
+ spacing: number | undefined;
17
17
  margin(value: any): this;
18
- domain: {
19
- x: any[];
20
- y: any[];
21
- };
22
18
  stat: any;
19
+ domain: any;
23
20
  data: any;
24
21
  refresh(): this;
25
22
  range: {
@@ -36,5 +33,6 @@ declare class Chart {
36
33
  } | undefined;
37
34
  aggregate(value: any, stat: any): void;
38
35
  ticks(axis: any, count: any, fontSize?: number): any;
36
+ #private;
39
37
  }
40
38
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/chart",
3
- "version": "1.0.0-next.150",
3
+ "version": "1.0.0-next.151",
4
4
  "type": "module",
5
5
  "description": "Data-driven chart components",
6
6
  "repository": {
@@ -99,19 +99,20 @@
99
99
  let lastTime = 0
100
100
  let rafId = 0
101
101
 
102
+ function advanceFrame() {
103
+ currentIndex = currentIndex + 1
104
+ if (currentIndex >= frameKeys.length) {
105
+ if (animate.loop ?? false) currentIndex = 0
106
+ else playing = false
107
+ }
108
+ }
109
+
102
110
  function tick(time) {
103
111
  if (!playing) return
104
112
  if (time - lastTime >= msPerFrame) {
105
113
  lastTime = time
106
- currentIndex = currentIndex + 1
107
- if (currentIndex >= frameKeys.length) {
108
- if (animate.loop ?? false) {
109
- currentIndex = 0
110
- } else {
111
- playing = false
112
- return
113
- }
114
- }
114
+ advanceFrame()
115
+ if (!playing) return
115
116
  }
116
117
  rafId = requestAnimationFrame(tick)
117
118
  }
package/src/Chart.svelte CHANGED
@@ -40,27 +40,24 @@
40
40
  const brewer = new ChartBrewer()
41
41
  setContext('chart-brewer', brewer)
42
42
 
43
+ function buildChannels() {
44
+ const channels = {}
45
+ if (x) channels.x = x
46
+ if (y) channels.y = y
47
+ if (color) channels.color = color
48
+ if (pattern) channels.pattern = pattern
49
+ if (fill) channels.fill = fill
50
+ if (size) channels.size = size
51
+ if (label) channels.label = label
52
+ if (symbol) channels.symbol = symbol
53
+ return channels
54
+ }
55
+
43
56
  $effect(() => {
44
57
  if (spec) {
45
- brewer.update({
46
- data: spec.data,
47
- channels: spec.channels,
48
- width,
49
- height,
50
- mode,
51
- layers: spec.layers
52
- })
58
+ brewer.update({ data: spec.data, channels: spec.channels, width, height, mode, layers: spec.layers })
53
59
  } else {
54
- const channels = {}
55
- if (x) channels.x = x
56
- if (y) channels.y = y
57
- if (color) channels.color = color
58
- if (pattern) channels.pattern = pattern
59
- if (fill) channels.fill = fill
60
- if (size) channels.size = size
61
- if (label) channels.label = label
62
- if (symbol) channels.symbol = symbol
63
- brewer.update({ data, channels, width, height, mode })
60
+ brewer.update({ data, channels: buildChannels(), width, height, mode })
64
61
  }
65
62
  })
66
63
 
package/src/Plot.svelte CHANGED
@@ -47,27 +47,8 @@
47
47
  children
48
48
  } = $props()
49
49
 
50
- // Create PlotState with initial values and provide as context.
51
- // untrack() suppresses "captures initial value" warnings — intentional:
52
- // the $effect below handles all subsequent reactive updates.
53
- const plotState = untrack(() => new PlotState({
54
- data: spec?.data ?? data,
55
- width: spec?.width ?? width,
56
- height: spec?.height ?? height,
57
- mode,
58
- margin,
59
- channels: spec ? { x: spec.x, y: spec.y, color: spec.color ?? spec.fill } : {},
60
- labels: spec?.labels ?? {},
61
- helpers,
62
- xDomain: spec?.xDomain,
63
- yDomain: spec?.yDomain,
64
- colorDomain: spec?.colorDomain
65
- }))
66
- setContext('plot-state', plotState)
67
-
68
- // Keep state in sync when reactive config changes
69
- $effect(() => {
70
- plotState.update({
50
+ function buildPlotConfig() {
51
+ return {
71
52
  data: spec?.data ?? data,
72
53
  width: spec?.width ?? width,
73
54
  height: spec?.height ?? height,
@@ -79,7 +60,18 @@
79
60
  xDomain: spec?.xDomain,
80
61
  yDomain: spec?.yDomain,
81
62
  colorDomain: spec?.colorDomain
82
- })
63
+ }
64
+ }
65
+
66
+ // Create PlotState with initial values and provide as context.
67
+ // untrack() suppresses "captures initial value" warnings — intentional:
68
+ // the $effect below handles all subsequent reactive updates.
69
+ const plotState = untrack(() => new PlotState(buildPlotConfig()))
70
+ setContext('plot-state', plotState)
71
+
72
+ // Keep state in sync when reactive config changes
73
+ $effect(() => {
74
+ plotState.update(buildPlotConfig())
83
75
  })
84
76
 
85
77
  const svgWidth = $derived(spec?.width ?? width)
@@ -131,7 +123,7 @@
131
123
  {@render children?.()}
132
124
 
133
125
  <!-- Spec-driven geoms -->
134
- {#each specGeoms as geomSpec}
126
+ {#each specGeoms as geomSpec (geomSpec.type)}
135
127
  {@const GeomComponent = resolveGeomComponent(geomSpec.type)}
136
128
  {#if GeomComponent}
137
129
  <GeomComponent
@@ -1,4 +1,5 @@
1
1
  import { untrack } from 'svelte'
2
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
2
3
  import { applyGeomStat } from './lib/plot/stat.js'
3
4
  import { inferFieldType, inferOrientation, buildUnifiedXScale, buildUnifiedYScale, inferColorScaleType } from './lib/plot/scales.js'
4
5
  import { resolvePreset } from './lib/plot/preset.js'
@@ -38,20 +39,29 @@ export class PlotState {
38
39
 
39
40
  // Effective channels: prefer top-level channels; fall back to first geom's channels
40
41
  // for the declarative API where no spec is provided.
42
+ #mergeGeomChannels(tc, geom) {
43
+ return {
44
+ x: tc.x ?? geom.channels?.x,
45
+ y: tc.y ?? geom.channels?.y,
46
+ color: tc.color ?? geom.channels?.color,
47
+ pattern: tc.pattern ?? geom.channels?.pattern,
48
+ symbol: tc.symbol ?? geom.channels?.symbol,
49
+ }
50
+ }
51
+
41
52
  #effectiveChannels = $derived.by(() => {
42
53
  const tc = this.#channels
43
54
  if (tc.x && tc.y) return tc
44
55
  const firstGeom = this.#geoms[0]
45
56
  if (!firstGeom) return tc
46
- return {
47
- x: tc.x ?? firstGeom.channels?.x,
48
- y: tc.y ?? firstGeom.channels?.y,
49
- color: tc.color ?? firstGeom.channels?.color,
50
- pattern: tc.pattern ?? firstGeom.channels?.pattern,
51
- symbol: tc.symbol ?? firstGeom.channels?.symbol,
52
- }
57
+ return this.#mergeGeomChannels(tc, firstGeom)
53
58
  })
54
59
 
60
+ #resolveXType(rawXType, yType) {
61
+ const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
62
+ return (hasBarGeom && rawXType === 'continuous' && yType === 'continuous') ? 'band' : rawXType
63
+ }
64
+
55
65
  orientation = $derived.by(() => {
56
66
  const xField = this.#effectiveChannels.x
57
67
  const yField = this.#effectiveChannels.y
@@ -59,9 +69,7 @@ export class PlotState {
59
69
  const rawXType = inferFieldType(this.#data, xField)
60
70
  const yType = inferFieldType(this.#data, yField)
61
71
  // Bar geoms treat numeric X as categorical (e.g. year on X → vertical bars).
62
- const hasBarGeom = this.#geoms.some((g) => g.type === 'bar')
63
- const xType = (hasBarGeom && rawXType === 'continuous' && yType === 'continuous') ? 'band' : rawXType
64
- return inferOrientation(xType, yType)
72
+ return inferOrientation(this.#resolveXType(rawXType, yType), yType)
65
73
  })
66
74
 
67
75
  colorScaleType = $derived.by(() => {
@@ -91,6 +99,44 @@ export class PlotState {
91
99
  })
92
100
  })
93
101
 
102
+ // For box/violin geoms, compute y domain from iqr_min/iqr_max instead of raw y values.
103
+ #resolveBoxDomain() {
104
+ const boxGeom = this.#geoms.find((g) => g.type === 'box' || g.type === 'violin')
105
+ if (!boxGeom) return null
106
+ const boxData = this.geomData(boxGeom.id)
107
+ const isValid = (v) => v !== null && v !== undefined && !isNaN(v)
108
+ const mins = boxData.map((d) => d.iqr_min).filter(isValid)
109
+ const maxs = boxData.map((d) => d.iqr_max).filter(isValid)
110
+ return (mins.length > 0 && maxs.length > 0) ? [Math.min(...mins), Math.max(...maxs)] : null
111
+ }
112
+
113
+ // For stacked bars, compute y domain from per-x column totals.
114
+ #resolveStackDomain(field) {
115
+ const stackGeom = this.#geoms.find((g) => g.options?.stack)
116
+ if (!stackGeom) return null
117
+ const xField = this.#effectiveChannels.x
118
+ const stackData = this.geomData(stackGeom.id)
119
+ if (!xField || stackData.length === 0) return null
120
+ // Mirror buildStackedBars/subBandFields: stack dimension is the first
121
+ // non-x field among [color, pattern]. Summing all raw rows (stat=identity)
122
+ // would overcount when multiple rows share the same (x, stack) key.
123
+ const colorField = this.#effectiveChannels.color
124
+ const patternField = this.#effectiveChannels.pattern
125
+ const stackField = [colorField, patternField].find((f) => f && f !== xField) ?? colorField
126
+ const lookup = new SvelteMap()
127
+ for (const d of stackData) {
128
+ const xVal = d[xField]
129
+ const cKey = stackField ? String(d[stackField]) : '_'
130
+ if (!lookup.has(xVal)) lookup.set(xVal, new SvelteMap())
131
+ lookup.get(xVal).set(cKey, Number(d[field]) || 0)
132
+ }
133
+ const totals = new SvelteMap()
134
+ for (const [xVal, colorMap] of lookup) {
135
+ totals.set(xVal, [...colorMap.values()].reduce((s, v) => s + v, 0))
136
+ }
137
+ return [0, Math.max(0, ...totals.values())]
138
+ }
139
+
94
140
  yScale = $derived.by(() => {
95
141
  const field = this.#effectiveChannels.y
96
142
  if (!field) return null
@@ -98,55 +144,8 @@ export class PlotState {
98
144
  ? this.#geoms.map((g) => this.geomData(g.id))
99
145
  : [this.#rawData]
100
146
  const includeZero = this.orientation === 'vertical'
101
-
102
- // For stacked bars, the y domain must cover the per-x column *total*, not the
103
- // per-row max — otherwise bars overflow the plot area.
104
- // For box/violin geoms, the processed data has iqr_min/iqr_max instead of raw y values.
105
- let yDomain = this.#yDomain
106
- if (!yDomain) {
107
- const boxGeom = this.#geoms.find((g) => g.type === 'box' || g.type === 'violin')
108
- if (boxGeom) {
109
- const boxData = this.geomData(boxGeom.id)
110
- const mins = boxData.map((d) => d.iqr_min).filter((v) => v !== null && v !== undefined && !isNaN(v))
111
- const maxs = boxData.map((d) => d.iqr_max).filter((v) => v !== null && v !== undefined && !isNaN(v))
112
- if (mins.length > 0 && maxs.length > 0) {
113
- yDomain = [Math.min(...mins), Math.max(...maxs)]
114
- }
115
- }
116
- }
117
- if (!yDomain) {
118
- const stackGeom = this.#geoms.find((g) => g.options?.stack)
119
- if (stackGeom) {
120
- const xField = this.#effectiveChannels.x
121
- const stackData = this.geomData(stackGeom.id)
122
- if (xField && stackData.length > 0) {
123
- // Mirror buildStackedBars/subBandFields: stack dimension is the first
124
- // non-x field among [color, pattern]. Summing all raw rows (stat=identity)
125
- // would overcount when multiple rows share the same (x, stack) key.
126
- const colorField = this.#effectiveChannels.color
127
- const patternField = this.#effectiveChannels.pattern
128
- const stackField = [colorField, patternField].find((f) => f && f !== xField) ?? colorField
129
- const lookup = new Map()
130
- for (const d of stackData) {
131
- const xVal = d[xField]
132
- const cKey = stackField ? String(d[stackField]) : '_'
133
- if (!lookup.has(xVal)) lookup.set(xVal, new Map())
134
- lookup.get(xVal).set(cKey, Number(d[field]) || 0)
135
- }
136
- const totals = new Map()
137
- for (const [xVal, colorMap] of lookup) {
138
- totals.set(xVal, [...colorMap.values()].reduce((s, v) => s + v, 0))
139
- }
140
- const maxStack = Math.max(0, ...totals.values())
141
- yDomain = [0, maxStack]
142
- }
143
- }
144
- }
145
-
146
- return buildUnifiedYScale(datasets, field, this.#innerHeight, {
147
- domain: yDomain,
148
- includeZero
149
- })
147
+ const yDomain = this.#yDomain ?? this.#resolveBoxDomain() ?? this.#resolveStackDomain(field)
148
+ return buildUnifiedYScale(datasets, field, this.#innerHeight, { domain: yDomain, includeZero })
150
149
  })
151
150
 
152
151
  // Colors: Map<colorKey, { fill, stroke }> for all distinct color field values.
@@ -162,15 +161,15 @@ export class PlotState {
162
161
  // and the pattern field is categorical (continuous fields can't be discretely patterned).
163
162
  patterns = $derived.by(() => {
164
163
  const pf = this.#effectiveChannels.pattern
165
- if (!pf) return new Map()
166
- if (inferFieldType(this.#data, pf) === 'continuous') return new Map()
164
+ if (!pf) return new SvelteMap()
165
+ if (inferFieldType(this.#data, pf) === 'continuous') return new SvelteMap()
167
166
  return assignPatterns(distinct(this.#data, pf))
168
167
  })
169
168
 
170
169
  // Symbols: Map<symbolKey, shapeName> — only populated when a symbol channel is set.
171
170
  symbols = $derived.by(() => {
172
171
  const sf = this.#effectiveChannels.symbol
173
- if (!sf) return new Map()
172
+ if (!sf) return new SvelteMap()
174
173
  return assignSymbols(distinct(this.#data, sf))
175
174
  })
176
175
 
@@ -180,7 +179,7 @@ export class PlotState {
180
179
  symbolField = $derived(this.#effectiveChannels.symbol)
181
180
 
182
181
  // Set of geom types currently registered (used by Legend to pick swatch style)
183
- geomTypes = $derived(new Set(this.#geoms.map((g) => g.type)))
182
+ geomTypes = $derived(new SvelteSet(this.#geoms.map((g) => g.type)))
184
183
 
185
184
  xAxisY = $derived.by(() => {
186
185
  if (!this.yScale || typeof this.yScale !== 'function') return this.#innerHeight
@@ -56,7 +56,7 @@
56
56
  <path d={areaPath} fill={fillColor} stroke="none" />
57
57
  <path d={linePath} fill="none" stroke={strokeColor} stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
58
58
  {:else if type === 'bar'}
59
- {#each values as v, i}
59
+ {#each values as v, i (i)}
60
60
  <rect
61
61
  x={xScale(i) - barWidth / 2}
62
62
  y={yScale(v)}
@@ -1,3 +1,5 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
2
+
1
3
  /**
2
4
  * Creates a reactive cross-filter state object.
3
5
  *
@@ -12,9 +14,25 @@
12
14
  *
13
15
  * @returns {CrossFilter}
14
16
  */
17
+
18
+ function toggleCategoricalInMap(filters, dimension, value) {
19
+ const existing = filters.get(dimension)
20
+ const set = existing instanceof Set ? new SvelteSet(existing) : new SvelteSet()
21
+ if (set.has(value)) {
22
+ set.delete(value)
23
+ } else {
24
+ set.add(value)
25
+ }
26
+ if (set.size === 0) {
27
+ filters.delete(dimension)
28
+ } else {
29
+ filters.set(dimension, set)
30
+ }
31
+ }
32
+
15
33
  export function createCrossFilter() {
16
34
  // Map<dimension, Set<unknown> | [number, number]>
17
- const filters = $state(new Map())
35
+ const filters = new SvelteMap()
18
36
 
19
37
  // Simple counter incremented on every mutation. Components read cf.version
20
38
  // inside $effect to reactively recompute when any filter changes.
@@ -58,18 +76,7 @@ export function createCrossFilter() {
58
76
  * @param {unknown} value
59
77
  */
60
78
  function toggleCategorical(dimension, value) {
61
- const existing = filters.get(dimension)
62
- const set = existing instanceof Set ? new Set(existing) : new Set()
63
- if (set.has(value)) {
64
- set.delete(value)
65
- } else {
66
- set.add(value)
67
- }
68
- if (set.size === 0) {
69
- filters.delete(dimension)
70
- } else {
71
- filters.set(dimension, set)
72
- }
79
+ toggleCategoricalInMap(filters, dimension, value)
73
80
  version++
74
81
  }
75
82
 
@@ -8,13 +8,15 @@
8
8
  let id = $state(null)
9
9
 
10
10
  // fill ?? x drives the colors map for both box interior and whisker strokes
11
+ const fillChannel = $derived(fill ?? x)
12
+
11
13
  onMount(() => {
12
- id = plotState.registerGeom({ type: 'box', channels: { x, y, color: fill ?? x }, stat, options })
14
+ id = plotState.registerGeom({ type: 'box', channels: { x, y, color: fillChannel }, stat, options })
13
15
  })
14
16
  onDestroy(() => { if (id) plotState.unregisterGeom(id) })
15
17
 
16
18
  $effect(() => {
17
- if (id) plotState.updateGeom(id, { channels: { x, y, color: fill ?? x }, stat })
19
+ if (id) plotState.updateGeom(id, { channels: { x, y, color: fillChannel }, stat })
18
20
  })
19
21
 
20
22
  const data = $derived(id ? plotState.geomData(id) : [])
@@ -24,7 +26,7 @@
24
26
 
25
27
  const boxes = $derived.by(() => {
26
28
  if (!data?.length || !xScale || !yScale) return []
27
- return buildBoxes(data, { x, fill: fill ?? x }, xScale, yScale, colors)
29
+ return buildBoxes(data, { x, fill: fillChannel }, xScale, yScale, colors)
28
30
  })
29
31
  </script>
30
32
 
@@ -14,8 +14,7 @@
14
14
  if (!label) return null
15
15
  if (label === true) return String(data[y] ?? '')
16
16
  if (typeof label === 'function') return String(label(data) ?? '')
17
- if (typeof label === 'string') return String(data[label] ?? '')
18
- return null
17
+ return typeof label === 'string' ? String(data[label] ?? '') : null
19
18
  }
20
19
 
21
20
  const plotState = getContext('plot-state')
@@ -14,8 +14,7 @@
14
14
  if (!label) return null
15
15
  if (label === true) return String(data[y] ?? '')
16
16
  if (typeof label === 'function') return String(label(data) ?? '')
17
- if (typeof label === 'string') return String(data[label] ?? '')
18
- return null
17
+ return typeof label === 'string' ? String(data[label] ?? '') : null
19
18
  }
20
19
 
21
20
  const plotState = getContext('plot-state')
@@ -36,18 +35,24 @@
36
35
  const colors = $derived(plotState.colors)
37
36
  const symbolMap = $derived(plotState.symbols)
38
37
 
39
- const sizeScale = $derived.by(() => {
38
+ function buildSizeScale() {
40
39
  if (!size || !data?.length) return null
41
40
  const vals = data.map((d) => Number(d[size])).filter((v) => !isNaN(v))
42
41
  if (!vals.length) return null
43
- const maxVal = Math.max(...vals)
44
42
  const minVal = Math.min(...vals)
45
- return scaleSqrt().domain([minVal, maxVal]).range([options.minRadius ?? 3, options.maxRadius ?? 20])
46
- })
43
+ const maxVal = Math.max(...vals)
44
+ const minRadius = options.minRadius ?? 3
45
+ const maxRadius = options.maxRadius ?? 20
46
+ return scaleSqrt().domain([minVal, maxVal]).range([minRadius, maxRadius])
47
+ }
48
+
49
+ const sizeScale = $derived.by(() => buildSizeScale())
50
+
51
+ const defaultRadius = $derived(options.radius ?? 4)
47
52
 
48
53
  const points = $derived.by(() => {
49
54
  if (!data?.length || !xScale || !yScale) return []
50
- return buildPoints(data, { x, y, color, size, symbol: symbolField }, xScale, yScale, colors, sizeScale, symbolMap, options.radius ?? 4)
55
+ return buildPoints(data, { x, y, color, size, symbol: symbolField }, xScale, yScale, colors, sizeScale, symbolMap, defaultRadius)
51
56
  })
52
57
  </script>
53
58
 
@@ -8,13 +8,15 @@
8
8
  let id = $state(null)
9
9
 
10
10
  // fill ?? x drives the colors map for both violin interior and outline
11
+ const fillChannel = $derived(fill ?? x)
12
+
11
13
  onMount(() => {
12
- id = plotState.registerGeom({ type: 'violin', channels: { x, y, color: fill ?? x }, stat, options })
14
+ id = plotState.registerGeom({ type: 'violin', channels: { x, y, color: fillChannel }, stat, options })
13
15
  })
14
16
  onDestroy(() => { if (id) plotState.unregisterGeom(id) })
15
17
 
16
18
  $effect(() => {
17
- if (id) plotState.updateGeom(id, { channels: { x, y, color: fill ?? x }, stat })
19
+ if (id) plotState.updateGeom(id, { channels: { x, y, color: fillChannel }, stat })
18
20
  })
19
21
 
20
22
  const data = $derived(id ? plotState.geomData(id) : [])
@@ -24,7 +26,7 @@
24
26
 
25
27
  const violins = $derived.by(() => {
26
28
  if (!data?.length || !xScale || !yScale) return []
27
- return buildViolins(data, { x, fill: fill ?? x }, xScale, yScale, colors)
29
+ return buildViolins(data, { x, fill: fillChannel }, xScale, yScale, colors)
28
30
  })
29
31
  </script>
30
32
 
@@ -1,3 +1,4 @@
1
+ import { SvelteSet } from 'svelte/reactivity'
1
2
  import { ChartBrewer } from './brewer.svelte.js'
2
3
  import { applyAggregate } from './stats.js'
3
4
 
@@ -10,7 +11,7 @@ export class CartesianBrewer extends ChartBrewer {
10
11
  if (stat === 'identity' || !channels.x || !channels.y) return data
11
12
  // Group by all mapped aesthetic dimensions so they survive aggregation.
12
13
  // e.g. x=region, fill=region, pattern=quarter → by=['region','quarter']
13
- const by = [...new Set([channels.x, channels.fill, channels.color, channels.pattern].filter(Boolean))]
14
+ const by = [...new SvelteSet([channels.x, channels.fill, channels.color, channels.pattern].filter(Boolean))]
14
15
  return applyAggregate(data, { by, value: channels.y, stat })
15
16
  }
16
17
  }
@@ -1,3 +1,4 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
1
2
  import { distinct, assignColors } from './colors.js'
2
3
  import { assignPatterns, toPatternId, PATTERN_ORDER } from './patterns.js'
3
4
  import { assignSymbols } from './symbols.js'
@@ -21,39 +22,39 @@ const DEFAULT_MARGIN = { top: 20, right: 20, bottom: 40, left: 50 }
21
22
  * @param {Map<unknown, string>} symbolMap
22
23
  * @returns {{ field: string, items: { label: string, fill: string|null, stroke: string|null, patternId: string|null, shape: string|null }[] }[]}
23
24
  */
25
+ function addAesthetic(byField, field, aesthetic, keys) {
26
+ if (byField.has(field)) {
27
+ byField.get(field).aesthetics.push(aesthetic)
28
+ } else {
29
+ byField.set(field, { aesthetics: [aesthetic], keys })
30
+ }
31
+ }
32
+
33
+ function buildLegendItem(key, aesthetics, colorMap, patternMap, symbolMap) {
34
+ const hasColor = aesthetics.includes('color')
35
+ return {
36
+ label: String(key),
37
+ fill: hasColor ? (colorMap.get(key)?.fill ?? null) : null,
38
+ stroke: hasColor ? (colorMap.get(key)?.stroke ?? null) : null,
39
+ patternId: aesthetics.includes('pattern') && patternMap.has(key) ? toPatternId(key) : null,
40
+ shape: aesthetics.includes('symbol') ? (symbolMap.get(key) ?? 'circle') : null
41
+ }
42
+ }
43
+
24
44
  export function buildLegendGroups(channels, colorMap, patternMap, symbolMap) {
25
45
  const cf = channels.fill ?? channels.color
26
46
  const { pattern: pf, symbol: sf } = channels
27
- const byField = new Map()
47
+ const byField = new SvelteMap()
28
48
 
29
- if (cf) {
30
- byField.set(cf, { aesthetics: ['color'], keys: [...colorMap.keys()] })
31
- }
32
- if (pf) {
33
- if (byField.has(pf)) {
34
- byField.get(pf).aesthetics.push('pattern')
35
- } else {
36
- byField.set(pf, { aesthetics: ['pattern'], keys: [...patternMap.keys()] })
37
- }
38
- }
39
- if (sf) {
40
- if (byField.has(sf)) {
41
- byField.get(sf).aesthetics.push('symbol')
42
- } else {
43
- byField.set(sf, { aesthetics: ['symbol'], keys: [...symbolMap.keys()] })
44
- }
45
- }
49
+ if (cf) byField.set(cf, { aesthetics: ['color'], keys: [...colorMap.keys()] })
50
+ if (pf) addAesthetic(byField, pf, 'pattern', [...patternMap.keys()])
51
+ if (sf) addAesthetic(byField, sf, 'symbol', [...symbolMap.keys()])
46
52
 
47
53
  return [...byField.entries()].map(([field, { aesthetics, keys }]) => ({
48
54
  field,
49
- items: keys.filter((k) => k !== null && k !== undefined).map((key) => ({
50
- label: String(key),
51
- fill: aesthetics.includes('color') ? (colorMap.get(key)?.fill ?? null) : null,
52
- stroke: aesthetics.includes('color') ? (colorMap.get(key)?.stroke ?? null) : null,
53
- patternId:
54
- aesthetics.includes('pattern') && patternMap.has(key) ? toPatternId(key) : null,
55
- shape: aesthetics.includes('symbol') ? (symbolMap.get(key) ?? 'circle') : null
56
- }))
55
+ items: keys
56
+ .filter((k) => k !== null && k !== undefined)
57
+ .map((key) => buildLegendItem(key, aesthetics, colorMap, patternMap, symbolMap))
57
58
  })).filter((group) => group.items.length > 0)
58
59
  }
59
60
 
@@ -93,14 +94,14 @@ export class ChartBrewer {
93
94
  colorMap = $derived(
94
95
  (this.#channels.fill ?? this.#channels.color)
95
96
  ? assignColors(distinct(this.#rawData, this.#channels.fill ?? this.#channels.color), this.#mode)
96
- : new Map()
97
+ : new SvelteMap()
97
98
  )
98
99
 
99
100
  /** @type {Map<unknown, string>} */
100
101
  patternMap = $derived(
101
102
  this.#channels.pattern
102
103
  ? assignPatterns(distinct(this.#rawData, this.#channels.pattern))
103
- : new Map()
104
+ : new SvelteMap()
104
105
  )
105
106
 
106
107
  /**
@@ -128,7 +129,7 @@ export class ChartBrewer {
128
129
  for (const [pk, name] of this.patternMap.entries()) {
129
130
  defs.push({ id: toPatternId(pk), name, fill: '#ddd', stroke: '#666' })
130
131
  }
131
- const seenComposite = new Set()
132
+ const seenComposite = new SvelteSet()
132
133
  for (const d of this.processedData) {
133
134
  const fk = d[ff]
134
135
  const pk = d[pf]
@@ -147,7 +148,7 @@ export class ChartBrewer {
147
148
  symbolMap = $derived(
148
149
  this.#channels.symbol
149
150
  ? assignSymbols(distinct(this.#rawData, this.#channels.symbol))
150
- : new Map()
151
+ : new SvelteMap()
151
152
  )
152
153
 
153
154
  get innerWidth() { return this.#width - this.#margin.left - this.#margin.right }
@@ -21,6 +21,11 @@ export function buildXScale(data, field, width, opts = {}) {
21
21
  .padding(opts.padding ?? 0.2)
22
22
  }
23
23
 
24
+ function maxFromLayer(layer) {
25
+ if (layer.data && layer.y) return max(layer.data, (d) => Number(d[layer.y])) ?? 0
26
+ return 0
27
+ }
28
+
24
29
  /**
25
30
  * Builds a y linear scale from 0 to max, extended by any layer overrides.
26
31
  * @param {Object[]} data
@@ -29,13 +34,8 @@ export function buildXScale(data, field, width, opts = {}) {
29
34
  * @param {Array<{data?: Object[], y?: string}>} layers
30
35
  */
31
36
  export function buildYScale(data, field, height, layers = []) {
32
- let maxVal = max(data, (d) => Number(d[field])) ?? 0
33
- for (const layer of layers) {
34
- if (layer.data && layer.y) {
35
- const layerMax = max(layer.data, (d) => Number(d[layer.y])) ?? 0
36
- if (layerMax > maxVal) maxVal = layerMax
37
- }
38
- }
37
+ const dataMax = max(data, (d) => Number(d[field])) ?? 0
38
+ const maxVal = layers.reduce((m, layer) => Math.max(m, maxFromLayer(layer)), dataMax)
39
39
  return scaleLinear().domain([0, maxVal]).range([height, 0]).nice()
40
40
  }
41
41
 
@@ -50,8 +50,12 @@ export function applyBoxStat(data, channels) {
50
50
  * @param {{ by: string[], value: string, stat: string|Function }} opts
51
51
  * @returns {Object[]}
52
52
  */
53
+ function isIdentityOrEmpty(stat, by, value) {
54
+ return stat === 'identity' || by.length === 0 || value === null || value === undefined
55
+ }
56
+
53
57
  export function applyAggregate(data, { by, value, stat }) {
54
- if (stat === 'identity' || by.length === 0 || value === null || value === undefined) return data
58
+ if (isIdentityOrEmpty(stat, by, value)) return data
55
59
  const fn = typeof stat === 'function' ? stat : STAT_FNS[stat]
56
60
  if (fn === null || fn === undefined) return data
57
61
  return dataset(data)
package/src/lib/chart.js CHANGED
@@ -46,7 +46,7 @@ class Chart {
46
46
  // padding
47
47
  // flipCoords = false
48
48
 
49
- constructor(data, opts) {
49
+ #initFields(opts) {
50
50
  this.width = Number(opts.width) || 2048
51
51
  this.height = Number(opts.height) || 2048
52
52
  this.flipCoords = opts.flipCoords || false
@@ -58,17 +58,18 @@ class Chart {
58
58
  this.fill = opts.fill || opts.x
59
59
  this.color = opts.color || opts.fill
60
60
  this.shape = opts.shape || opts.fill
61
-
62
61
  this.padding = opts.padding !== undefined ? Number(opts.padding) : 32
63
-
64
- this.spacing =
65
- Number(opts.spacing) >= 0 && Number(opts.spacing) <= 0.5 ? Number(opts.spacing) : 0
62
+ this.spacing = (Number(opts.spacing) >= 0 && Number(opts.spacing) <= 0.5) ? Number(opts.spacing) : 0
66
63
  this.margin = {
67
64
  top: Number(opts.margin?.top) || 0,
68
65
  left: Number(opts.margin?.left) || 0,
69
66
  right: Number(opts.margin?.right) || 0,
70
67
  bottom: Number(opts.margin?.bottom) || 0
71
68
  }
69
+ this.stat = opts.stat || 'identity'
70
+ }
71
+
72
+ #initDomain(data) {
72
73
  this.domain = {
73
74
  x: [...new Set(data.map((d) => d[this.x]))],
74
75
  y: [...new Set(data.map((d) => d[this.y]))]
@@ -76,8 +77,9 @@ class Chart {
76
77
  if (this.flipCoords) {
77
78
  this.domain = { y: this.domain.x, x: this.domain.y }
78
79
  }
79
- this.stat = opts.stat || 'identity'
80
+ }
80
81
 
82
+ #initData(data) {
81
83
  this.data = data.map((d) => ({
82
84
  x: this.flipCoords ? d[this.y] : d[this.x],
83
85
  y: this.flipCoords ? d[this.x] : d[this.y],
@@ -85,7 +87,12 @@ class Chart {
85
87
  color: d[this.color]
86
88
  // shape: d[this.shape]
87
89
  }))
90
+ }
88
91
 
92
+ constructor(data, opts) {
93
+ this.#initFields(opts)
94
+ this.#initDomain(data)
95
+ this.#initData(data)
89
96
  this.refresh()
90
97
  }
91
98
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  <pattern {id} patternUnits="userSpaceOnUse" width={size} height={size}>
13
13
  <rect width={size} height={size} fill="none" />
14
- {#each resolvedMarks as { type, attrs }}
14
+ {#each resolvedMarks as { type, attrs }, i (i)}
15
15
  {#if type === 'line'}
16
16
  <line {...attrs} />
17
17
  {:else if type === 'circle'}
@@ -1,3 +0,0 @@
1
- # Patterns
2
-
3
- These patterns are inspired from https://superdesigner.co/tools/svg-backgrounds