@rokkit/chart 1.0.0-next.99 → 1.0.2

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.
Files changed (137) hide show
  1. package/README.md +150 -46
  2. package/package.json +40 -51
  3. package/src/AnimatedPlot.svelte +383 -0
  4. package/src/Chart.svelte +87 -59
  5. package/src/ChartExporter.svelte +125 -0
  6. package/src/ChartLegend.svelte +148 -0
  7. package/src/ChartProvider.svelte +10 -0
  8. package/src/FacetPlot/Panel.svelte +37 -0
  9. package/src/FacetPlot.svelte +114 -0
  10. package/src/Plot/Arc.svelte +29 -0
  11. package/src/Plot/Area.svelte +32 -0
  12. package/src/Plot/Axis.svelte +164 -0
  13. package/src/Plot/Bar.svelte +54 -0
  14. package/src/Plot/Grid.svelte +34 -0
  15. package/src/Plot/Legend.svelte +268 -0
  16. package/src/Plot/Line.svelte +37 -0
  17. package/src/Plot/Point.svelte +40 -0
  18. package/src/Plot/Root.svelte +62 -0
  19. package/src/Plot/Timeline.svelte +95 -0
  20. package/src/Plot/Tooltip.svelte +87 -0
  21. package/src/Plot/index.js +9 -0
  22. package/src/Plot.svelte +313 -0
  23. package/src/PlotState.svelte.js +375 -0
  24. package/src/Sparkline.svelte +108 -0
  25. package/src/Symbol.svelte +15 -11
  26. package/src/Texture.svelte +9 -7
  27. package/src/charts/AreaChart.svelte +30 -0
  28. package/src/charts/BarChart.svelte +31 -0
  29. package/src/charts/BoxPlot.svelte +24 -0
  30. package/src/charts/BubbleChart.svelte +28 -0
  31. package/src/charts/LineChart.svelte +31 -0
  32. package/src/charts/PieChart.svelte +35 -0
  33. package/src/charts/ScatterPlot.svelte +31 -0
  34. package/src/charts/ViolinPlot.svelte +24 -0
  35. package/src/crossfilter/CrossFilter.svelte +42 -0
  36. package/src/crossfilter/FilterBar.svelte +24 -0
  37. package/src/crossfilter/FilterHistogram.svelte +290 -0
  38. package/src/crossfilter/FilterSlider.svelte +83 -0
  39. package/src/crossfilter/createCrossFilter.svelte.js +124 -0
  40. package/src/elements/Bar.svelte +20 -16
  41. package/src/elements/ColorRamp.svelte +19 -19
  42. package/src/elements/ContinuousLegend.svelte +41 -19
  43. package/src/elements/DefinePatterns.svelte +9 -7
  44. package/src/elements/DiscreteLegend.svelte +13 -11
  45. package/src/elements/Label.svelte +4 -9
  46. package/src/elements/SymbolGrid.svelte +8 -9
  47. package/src/examples/BarChartExample.svelte +81 -0
  48. package/src/geoms/Arc.svelte +126 -0
  49. package/src/geoms/Area.svelte +78 -0
  50. package/src/geoms/Bar.svelte +200 -0
  51. package/src/geoms/Box.svelte +113 -0
  52. package/src/geoms/Candlestick.svelte +110 -0
  53. package/src/geoms/Heatmap.svelte +92 -0
  54. package/src/geoms/Hexbin.svelte +114 -0
  55. package/src/geoms/LabelPill.svelte +17 -0
  56. package/src/geoms/Line.svelte +123 -0
  57. package/src/geoms/Point.svelte +145 -0
  58. package/src/geoms/Ribbon.svelte +161 -0
  59. package/src/geoms/Violin.svelte +56 -0
  60. package/src/geoms/Waterfall.svelte +114 -0
  61. package/src/geoms/lib/areas.js +154 -0
  62. package/src/geoms/lib/bars.js +223 -0
  63. package/src/index.js +81 -2
  64. package/src/lib/brewing/BoxBrewer.svelte.js +14 -0
  65. package/src/lib/brewing/CartesianBrewer.svelte.js +21 -0
  66. package/src/lib/brewing/PieBrewer.svelte.js +14 -0
  67. package/src/lib/brewing/QuartileBrewer.svelte.js +51 -0
  68. package/src/lib/brewing/ViolinBrewer.svelte.js +14 -0
  69. package/src/lib/brewing/axes.svelte.js +270 -0
  70. package/src/lib/brewing/bars.svelte.js +201 -0
  71. package/src/lib/brewing/brewer.svelte.js +277 -0
  72. package/src/lib/brewing/colors.js +157 -0
  73. package/src/lib/brewing/dimensions.svelte.js +56 -0
  74. package/src/lib/brewing/index.svelte.js +205 -0
  75. package/src/lib/brewing/legends.svelte.js +137 -0
  76. package/src/lib/brewing/marks/arcs.js +43 -0
  77. package/src/lib/brewing/marks/areas.js +72 -0
  78. package/src/lib/brewing/marks/bars.js +49 -0
  79. package/src/lib/brewing/marks/boxes.js +75 -0
  80. package/src/lib/brewing/marks/lines.js +55 -0
  81. package/src/lib/brewing/marks/points.js +105 -0
  82. package/src/lib/brewing/marks/violins.js +90 -0
  83. package/src/lib/brewing/patterns.js +45 -0
  84. package/src/lib/brewing/scales.js +51 -0
  85. package/src/lib/brewing/scales.svelte.js +82 -0
  86. package/src/lib/brewing/stats.js +74 -0
  87. package/src/lib/brewing/symbols.js +10 -0
  88. package/src/lib/brewing/types.js +73 -0
  89. package/src/lib/chart.js +16 -8
  90. package/src/lib/context.js +131 -0
  91. package/src/lib/keyboard-nav.js +37 -0
  92. package/src/lib/plot/chartProps.js +76 -0
  93. package/src/lib/plot/crossfilter.js +16 -0
  94. package/src/lib/plot/facet.js +58 -0
  95. package/src/lib/plot/frames.js +81 -0
  96. package/src/lib/plot/helpers.js +14 -0
  97. package/src/lib/plot/preset.js +67 -0
  98. package/src/lib/plot/scales.js +81 -0
  99. package/src/lib/plot/stat.js +92 -0
  100. package/src/lib/plot/types.js +65 -0
  101. package/src/lib/preset.js +41 -0
  102. package/src/lib/scales.svelte.js +151 -0
  103. package/src/lib/swatch.js +4 -7
  104. package/src/lib/utils.js +142 -0
  105. package/src/lib/xscale.js +31 -0
  106. package/src/patterns/DefinePatterns.svelte +32 -0
  107. package/src/patterns/PatternDef.svelte +27 -0
  108. package/src/patterns/index.js +4 -14
  109. package/src/patterns/patterns.js +360 -0
  110. package/src/patterns/scale.js +116 -0
  111. package/src/spec/chart-spec.js +72 -0
  112. package/src/symbols/RoundedSquare.svelte +18 -12
  113. package/src/symbols/Shape.svelte +22 -16
  114. package/src/symbols/constants/index.js +1 -1
  115. package/LICENSE +0 -21
  116. package/src/lib/index.js +0 -4
  117. package/src/lib/plots.js +0 -27
  118. package/src/patterns/Brick.svelte +0 -17
  119. package/src/patterns/Circles.svelte +0 -18
  120. package/src/patterns/CrossHatch.svelte +0 -14
  121. package/src/patterns/CurvedWave.svelte +0 -9
  122. package/src/patterns/Dots.svelte +0 -19
  123. package/src/patterns/OutlineCircles.svelte +0 -15
  124. package/src/patterns/Tile.svelte +0 -17
  125. package/src/patterns/Triangles.svelte +0 -15
  126. package/src/patterns/Waves.svelte +0 -13
  127. package/src/patterns/paths/NamedPattern.svelte +0 -12
  128. package/src/patterns/paths/constants.js +0 -4
  129. package/src/plots/Plot.svelte +0 -40
  130. package/src/plots/index.js +0 -1
  131. package/src/template/Texture.svelte +0 -16
  132. package/src/template/constants.js +0 -43
  133. package/src/template/shapes/Circles.svelte +0 -16
  134. package/src/template/shapes/Lines.svelte +0 -17
  135. package/src/template/shapes/Path.svelte +0 -12
  136. package/src/template/shapes/Polygons.svelte +0 -18
  137. package/src/template/shapes/index.js +0 -4
@@ -0,0 +1,290 @@
1
+ <script>
2
+ // @ts-nocheck
3
+ import { getContext } from 'svelte'
4
+ import { bin, extent, max } from 'd3-array'
5
+ import { scaleLinear } from 'd3-scale'
6
+ import { format } from 'd3-format'
7
+
8
+ /**
9
+ * Interactive histogram with brush selection for crossfilter range dimensions.
10
+ * Renders bins as bars; mouse drag selects a range and calls cf.setRange().
11
+ * Click without dragging clears the filter.
12
+ */
13
+ let {
14
+ data = [],
15
+ field = '',
16
+ label = '',
17
+ bins: binCount = 20,
18
+ width = 280,
19
+ height = 120,
20
+ color = 'rgb(var(--color-primary, 249 115 22))',
21
+ dimColor = 'rgb(var(--color-surface-z3, 203 213 225))'
22
+ } = $props()
23
+
24
+ const cf = getContext('crossfilter')
25
+
26
+ const margin = { top: 8, right: 10, bottom: 22, left: 32 }
27
+
28
+ const innerWidth = $derived(width - margin.left - margin.right)
29
+ const innerHeight = $derived(height - margin.top - margin.bottom)
30
+
31
+ // Compute histogram bins from data
32
+ const binner = $derived(
33
+ bin()
34
+ .value((d) => d[field])
35
+ .thresholds(binCount)
36
+ )
37
+ const binned = $derived(data.length > 0 ? binner(data) : [])
38
+ const domainExtent = $derived(data.length > 0 ? extent(data, (d) => d[field]) : [0, 1])
39
+
40
+ // Scales
41
+ const xScale = $derived(
42
+ scaleLinear()
43
+ .domain(domainExtent)
44
+ .range([0, innerWidth])
45
+ .nice()
46
+ )
47
+ const yScale = $derived(
48
+ binned.length > 0
49
+ ? scaleLinear()
50
+ .domain([0, max(binned, (d) => d.length) ?? 1])
51
+ .range([innerHeight, 0])
52
+ .nice()
53
+ : scaleLinear().domain([0, 1]).range([innerHeight, 0])
54
+ )
55
+
56
+ // Active range from crossfilter
57
+ const activeRange = $derived(cf?.filters?.get(field))
58
+
59
+ // Brush state
60
+ let brushStartPx = $state(null)
61
+ let brushEndPx = $state(null)
62
+ let brushing = $state(false)
63
+
64
+ // Brush rect in pixel space
65
+ const brushRect = $derived.by(() => {
66
+ // Show committed range if not currently brushing
67
+ if (brushStartPx === null && activeRange) {
68
+ const [lo, hi] = activeRange
69
+ return {
70
+ x: xScale(lo),
71
+ width: Math.max(1, xScale(hi) - xScale(lo))
72
+ }
73
+ }
74
+ if (brushStartPx === null) return null
75
+ const lo = Math.min(brushStartPx, brushEndPx ?? brushStartPx)
76
+ const hi = Math.max(brushStartPx, brushEndPx ?? brushStartPx)
77
+ return { x: lo, width: Math.max(1, hi - lo) }
78
+ })
79
+
80
+ function pixelToValue(px) {
81
+ return xScale.invert(Math.max(0, Math.min(innerWidth, px)))
82
+ }
83
+
84
+ function getLocalX(e) {
85
+ const svgEl = e.currentTarget.closest('svg')
86
+ const rect = svgEl.getBoundingClientRect()
87
+ return e.clientX - rect.left - margin.left
88
+ }
89
+
90
+ function onMouseDown(e) {
91
+ brushStartPx = getLocalX(e)
92
+ brushEndPx = brushStartPx
93
+ brushing = true
94
+ }
95
+
96
+ function onMouseMove(e) {
97
+ if (!brushing) return
98
+ brushEndPx = getLocalX(e)
99
+ }
100
+
101
+ function onMouseUp() {
102
+ if (!brushing) return
103
+ brushing = false
104
+ const lo = Math.min(brushStartPx, brushEndPx)
105
+ const hi = Math.max(brushStartPx, brushEndPx)
106
+ if (hi - lo < 3) {
107
+ // Treat as click — clear filter
108
+ cf?.clearFilter(field)
109
+ brushStartPx = null
110
+ brushEndPx = null
111
+ return
112
+ }
113
+ const loVal = pixelToValue(lo)
114
+ const hiVal = pixelToValue(hi)
115
+ cf?.setRange(field, [loVal, hiVal])
116
+ brushStartPx = null
117
+ brushEndPx = null
118
+ }
119
+
120
+ // Check if a bin is within active range
121
+ function binInRange(b) {
122
+ if (!activeRange) return true
123
+ const [lo, hi] = activeRange
124
+ return b.x1 > lo && b.x0 < hi
125
+ }
126
+
127
+ // Y-axis tick values (2-3 ticks)
128
+ const yTicks = $derived(yScale.ticks(3))
129
+ // X-axis tick values (4 ticks)
130
+ const xTicks = $derived(xScale.ticks(4))
131
+
132
+ const fmt = format('.2~s')
133
+ </script>
134
+
135
+ <div data-filter-histogram data-filter-field={field} style="width:{width}px">
136
+ {#if label}
137
+ <div data-filter-histogram-label>{label}</div>
138
+ {/if}
139
+ <svg
140
+ {width}
141
+ {height}
142
+ style="display:block;cursor:crosshair;user-select:none"
143
+ role="img"
144
+ aria-label="Histogram filter for {label || field}"
145
+ onmousedown={onMouseDown}
146
+ onmousemove={onMouseMove}
147
+ onmouseup={onMouseUp}
148
+ onmouseleave={onMouseUp}
149
+ >
150
+ <g transform="translate({margin.left},{margin.top})">
151
+ <!-- Y axis ticks -->
152
+ {#each yTicks as tick}
153
+ <line
154
+ x1={0}
155
+ x2={innerWidth}
156
+ y1={yScale(tick)}
157
+ y2={yScale(tick)}
158
+ stroke="currentColor"
159
+ stroke-opacity="0.1"
160
+ stroke-width="1"
161
+ />
162
+ <text
163
+ x={-4}
164
+ y={yScale(tick)}
165
+ text-anchor="end"
166
+ dominant-baseline="middle"
167
+ font-size="9"
168
+ fill="currentColor"
169
+ opacity="0.5">{tick}</text
170
+ >
171
+ {/each}
172
+
173
+ <!-- X axis line -->
174
+ <line
175
+ x1={0}
176
+ x2={innerWidth}
177
+ y1={innerHeight}
178
+ y2={innerHeight}
179
+ stroke="currentColor"
180
+ stroke-opacity="0.2"
181
+ />
182
+
183
+ <!-- X axis ticks -->
184
+ {#each xTicks as tick}
185
+ <text
186
+ x={xScale(tick)}
187
+ y={innerHeight + 12}
188
+ text-anchor="middle"
189
+ font-size="9"
190
+ fill="currentColor"
191
+ opacity="0.5">{fmt(tick)}</text
192
+ >
193
+ {/each}
194
+
195
+ <!-- Bars -->
196
+ {#each binned as b}
197
+ {@const bx = xScale(b.x0) + 1}
198
+ {@const bw = Math.max(0, xScale(b.x1) - xScale(b.x0) - 1)}
199
+ {@const by = yScale(b.length)}
200
+ {@const bh = innerHeight - by}
201
+ <rect
202
+ x={bx}
203
+ y={by}
204
+ width={bw}
205
+ height={bh}
206
+ fill={activeRange ? (binInRange(b) ? color : dimColor) : color}
207
+ opacity="0.85"
208
+ data-filter-histogram-bar
209
+ />
210
+ {/each}
211
+
212
+ <!-- Brush overlay rect -->
213
+ {#if brushRect}
214
+ <rect
215
+ x={brushRect.x}
216
+ y={0}
217
+ width={brushRect.width}
218
+ height={innerHeight}
219
+ fill={color}
220
+ opacity="0.15"
221
+ pointer-events="none"
222
+ />
223
+ <rect
224
+ x={brushRect.x}
225
+ y={0}
226
+ width={1}
227
+ height={innerHeight}
228
+ fill={color}
229
+ opacity="0.6"
230
+ pointer-events="none"
231
+ />
232
+ <rect
233
+ x={brushRect.x + brushRect.width - 1}
234
+ y={0}
235
+ width={1}
236
+ height={innerHeight}
237
+ fill={color}
238
+ opacity="0.6"
239
+ pointer-events="none"
240
+ />
241
+ {/if}
242
+ </g>
243
+ </svg>
244
+ {#if activeRange}
245
+ <div data-filter-histogram-range>
246
+ {fmt(activeRange[0])} – {fmt(activeRange[1])}
247
+ <button
248
+ data-filter-histogram-clear
249
+ onclick={() => cf?.clearFilter(field)}
250
+ aria-label="Clear {label || field} filter"
251
+ >✕</button>
252
+ </div>
253
+ {/if}
254
+ </div>
255
+
256
+ <style>
257
+ [data-filter-histogram] {
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 4px;
261
+ font-size: 12px;
262
+ color: rgb(var(--color-surface-z6, 100 116 139));
263
+ }
264
+ [data-filter-histogram-label] {
265
+ font-weight: 600;
266
+ font-size: 11px;
267
+ text-transform: uppercase;
268
+ letter-spacing: 0.05em;
269
+ opacity: 0.7;
270
+ }
271
+ [data-filter-histogram-range] {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: 6px;
275
+ font-size: 11px;
276
+ opacity: 0.8;
277
+ }
278
+ [data-filter-histogram-clear] {
279
+ background: none;
280
+ border: none;
281
+ cursor: pointer;
282
+ padding: 0 2px;
283
+ font-size: 10px;
284
+ opacity: 0.6;
285
+ line-height: 1;
286
+ }
287
+ [data-filter-histogram-clear]:hover {
288
+ opacity: 1;
289
+ }
290
+ </style>
@@ -0,0 +1,83 @@
1
+ <script>
2
+ import { getContext } from 'svelte'
3
+
4
+ /**
5
+ * Dual range slider for a continuous crossfilter dimension.
6
+ * NOTE: Interim implementation using HTML range inputs.
7
+ * The spec calls for a Plot+Point+brush architecture, deferred until brush geom is implemented.
8
+ */
9
+ let { field, min, max, step = 0.1, label = '' } = $props()
10
+
11
+ const cf = getContext('crossfilter')
12
+
13
+ // Initialize from props; $effect keeps in sync when min/max change
14
+ let low = $state(0)
15
+ let high = $state(100)
16
+
17
+ $effect(() => {
18
+ low = min ?? 0
19
+ high = max ?? 100
20
+ })
21
+
22
+ function handleLow(e) {
23
+ low = Math.min(Number(e.currentTarget.value), high)
24
+ cf?.setRange(field, [low, high])
25
+ }
26
+
27
+ function handleHigh(e) {
28
+ high = Math.max(Number(e.currentTarget.value), low)
29
+ cf?.setRange(field, [low, high])
30
+ }
31
+ </script>
32
+
33
+ <div data-filter-slider data-filter-field={field}>
34
+ {#if label}
35
+ <span data-filter-slider-label>{label}</span>
36
+ {/if}
37
+ <div data-filter-slider-inputs>
38
+ <input
39
+ type="range"
40
+ {min}
41
+ {max}
42
+ {step}
43
+ value={low}
44
+ oninput={handleLow}
45
+ aria-label="Minimum {label || field}"
46
+ data-filter-slider-low
47
+ />
48
+ <input
49
+ type="range"
50
+ {min}
51
+ {max}
52
+ {step}
53
+ value={high}
54
+ oninput={handleHigh}
55
+ aria-label="Maximum {label || field}"
56
+ data-filter-slider-high
57
+ />
58
+ </div>
59
+ <div data-filter-slider-display>
60
+ {low} – {high}
61
+ </div>
62
+ </div>
63
+
64
+ <style>
65
+ [data-filter-slider] {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 4px;
69
+ font-size: 12px;
70
+ }
71
+ [data-filter-slider-inputs] {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 2px;
75
+ }
76
+ [data-filter-slider-label] {
77
+ font-weight: 600;
78
+ }
79
+ [data-filter-slider-display] {
80
+ color: currentColor;
81
+ opacity: 0.7;
82
+ }
83
+ </style>
@@ -0,0 +1,124 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
2
+
3
+ /**
4
+ * Creates a reactive cross-filter state object.
5
+ *
6
+ * Filter values follow the spec type:
7
+ * FilterState = Map<string, Set<unknown> | [number, number]>
8
+ * - categorical: raw Set of selected values
9
+ * - continuous: [min, max] tuple
10
+ *
11
+ * Exposes a `filters` getter so CrossFilter.svelte can bind to current state.
12
+ * Exposes a `version` counter that increments on every mutation, giving
13
+ * components a simple reactive signal to watch for filter changes.
14
+ *
15
+ * @returns {CrossFilter}
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
+
33
+ export function createCrossFilter() {
34
+ // Map<dimension, Set<unknown> | [number, number]>
35
+ const filters = new SvelteMap()
36
+
37
+ // Simple counter incremented on every mutation. Components read cf.version
38
+ // inside $effect to reactively recompute when any filter changes.
39
+ let version = $state(0)
40
+
41
+ /**
42
+ * Returns true if any filter is active on this dimension.
43
+ * @param {string} dimension
44
+ */
45
+ function isFiltered(dimension) {
46
+ const f = filters.get(dimension)
47
+ if (!f) return false
48
+ if (f instanceof Set) return f.size > 0
49
+ return true // range: always active if present
50
+ }
51
+
52
+ /**
53
+ * Returns true if a value on this dimension is NOT in the active filter.
54
+ * Returns false when no filter is active on this dimension.
55
+ *
56
+ * @param {string} dimension
57
+ * @param {unknown} value
58
+ */
59
+ function isDimmed(dimension, value) {
60
+ const f = filters.get(dimension)
61
+ if (!f) return false
62
+ if (f instanceof Set) {
63
+ return !f.has(value)
64
+ }
65
+ // [min, max] range
66
+ const [lo, hi] = f
67
+ return Number(value) < lo || Number(value) > hi
68
+ }
69
+
70
+ /**
71
+ * Toggles a categorical value for a dimension.
72
+ * Adds when absent, removes when present.
73
+ * Clears the dimension filter when the last value is removed.
74
+ *
75
+ * @param {string} dimension
76
+ * @param {unknown} value
77
+ */
78
+ function toggleCategorical(dimension, value) {
79
+ toggleCategoricalInMap(filters, dimension, value)
80
+ version++
81
+ }
82
+
83
+ /**
84
+ * Sets a continuous range filter for a dimension.
85
+ * @param {string} dimension
86
+ * @param {[number, number]} range
87
+ */
88
+ function setRange(dimension, range) {
89
+ filters.set(dimension, [range[0], range[1]])
90
+ version++
91
+ }
92
+
93
+ /**
94
+ * Clears the filter for a single dimension.
95
+ * @param {string} dimension
96
+ */
97
+ function clearFilter(dimension) {
98
+ filters.delete(dimension)
99
+ version++
100
+ }
101
+
102
+ /** Clears all active filters. */
103
+ function clearAll() {
104
+ filters.clear()
105
+ version++
106
+ }
107
+
108
+ return {
109
+ /** @readonly — reactive Map of current filter state */
110
+ get filters() {
111
+ return filters
112
+ },
113
+ /** @readonly — increments on every mutation; read inside $effect to react to any filter change */
114
+ get version() {
115
+ return version
116
+ },
117
+ isFiltered,
118
+ isDimmed,
119
+ toggleCategorical,
120
+ setRange,
121
+ clearFilter,
122
+ clearAll
123
+ }
124
+ }
@@ -1,29 +1,33 @@
1
1
  <script>
2
2
  import { format } from 'd3-format'
3
+ import { get } from 'svelte/store'
3
4
  import Label from './Label.svelte'
4
5
 
5
- export let rank
6
- export let value
7
- export let name
8
- export let formatString = '.1%'
9
- export let scales
10
- export let height = 60
11
- export let fill
12
- export let spaceBetween = 5
6
+ let {
7
+ rank,
8
+ value,
9
+ name,
10
+ formatString = '.1%',
11
+ scales,
12
+ height = 60,
13
+ fill,
14
+ spaceBetween = 5
15
+ } = $props()
13
16
 
14
17
  const textHeight = 16
15
18
  const charWidth = 12
16
- $: y = rank * (height + spaceBetween)
17
- $: width = $scales.x(value)
19
+ let y = $derived(rank * (height + spaceBetween))
20
+ let width = $derived(get(scales).x(value))
18
21
 
19
- $: textWidth = name.length * charWidth
20
- $: textOffset = width <= textWidth ? width + charWidth : width
21
- $: textAnchor = textOffset > width ? 'start' : 'end'
22
+ let textWidth = $derived(name.length * charWidth)
23
+ let textOffset = $derived(width <= textWidth ? width + charWidth : width)
24
+ let textAnchor = $derived(textOffset > width ? 'start' : 'end')
22
25
 
23
- $: formattedValue = format(formatString)(value)
26
+ let formattedValue = $derived(format(formatString)(value))
27
+ let xOrigin = $derived(get(scales).x(0))
24
28
  </script>
25
29
 
26
- <rect x={$scales.x(0)} {y} {width} {height} {fill} opacity={0.5} />
27
- <rect x={$scales.x(0)} {y} width={5} {height} {fill} />
30
+ <rect x={xOrigin} {y} {width} {height} {fill} opacity={0.5} />
31
+ <rect x={xOrigin} {y} width={5} {height} {fill} />
28
32
  <Label x={width} y={y + textHeight + 8} anchor={textAnchor} text={name} />
29
33
  <Label x={width} y={y + height - 14} anchor={textAnchor} text={formattedValue} small />
@@ -2,36 +2,36 @@
2
2
  import { scaleLinear } from 'd3-scale'
3
3
  import { id as uniqueId } from '@rokkit/core'
4
4
 
5
- export let x = 0
6
- export let y = 0
7
- export let textSize = 5
8
- export let height = 10
9
- export let width = 100
10
- export let tickCount = 5
11
- export let scale
5
+ let { x = 0, y = 0, textSize = 5, height = 10, width = 100, tickCount = 5, scale } = $props()
12
6
 
13
- $: scaleTicks = scaleLinear()
14
- .range([x, x + width])
15
- .domain(scale.domain())
16
- $: scalePercent = scaleLinear().range([0, 100]).domain(scale.domain())
17
- $: ticks = scale.ticks.apply(scale, [tickCount]).map((d) => ({ x: scaleTicks(d), value: d }))
7
+ let scaleTicks = $derived(
8
+ scaleLinear()
9
+ .range([x, x + width])
10
+ .domain(scale.domain())
11
+ )
12
+ let scalePercent = $derived(scaleLinear().range([0, 100]).domain(scale.domain()))
13
+ let ticks = $derived(
14
+ scale.ticks.apply(scale, [tickCount]).map((d) => ({ x: scaleTicks(d), value: d }))
15
+ )
18
16
 
19
- $: colors = ticks.map(({ value }) => ({
20
- color: scale(value),
21
- offset: `${scalePercent(value)}%`
22
- }))
23
- $: id = uniqueId('legend-')
17
+ let colors = $derived(
18
+ ticks.map(({ value }) => ({
19
+ color: scale(value),
20
+ offset: `${scalePercent(value)}%`
21
+ }))
22
+ )
23
+ let id = $state(uniqueId('legend-'))
24
24
  </script>
25
25
 
26
26
  <defs>
27
27
  <linearGradient {id}>
28
- {#each colors as { color, offset }}
28
+ {#each colors as { color, offset }, index (index)}
29
29
  <stop stop-color={color} {offset} />
30
30
  {/each}
31
31
  </linearGradient>
32
32
  </defs>
33
33
  <rect {x} y={y + height} {width} {height} fill="url(#{id})" />
34
- {#each ticks as { x, value }}
34
+ {#each ticks as { x, value }, index (index)}
35
35
  <line x1={x} y1={y + (2 * height) / 3} x2={x} y2={y + height * 2} />
36
36
  <text {x} y={y + height / 2} font-size={textSize}>{value}</text>
37
37
  {/each}
@@ -1,37 +1,59 @@
1
1
  <script>
2
2
  import { scaleLinear } from 'd3-scale'
3
- // import { id as uniqueId } from '@rokkit/core'
4
3
 
5
- export let x = 0
6
- export let y = 0
7
- export let textSize = 5
8
- export let height = 10
9
- export let width = 100
10
- export let tickCount = 5
11
- export let scale
12
- export let id = 'legend'
4
+ let {
5
+ x = 0,
6
+ y = 0,
7
+ textSize = 5,
8
+ height = 10,
9
+ width = 100,
10
+ tickCount = 5,
11
+ scale,
12
+ id = 'legend'
13
+ } = $props()
13
14
 
14
- $: scaleTicks = scaleLinear()
15
- .range([x, x + 100])
16
- .domain(scale.domain())
17
- $: ticks = scale.ticks.apply(scale, [tickCount]).map((d) => ({ x: scaleTicks(d), label: d }))
15
+ let scaleTicks = $derived(
16
+ scaleLinear()
17
+ .range([x, x + width])
18
+ .domain(scale.domain())
19
+ )
18
20
 
19
- $: colors = scale.range()
20
- // $: id = uniqueId('legend-')
21
+ let ticks = $derived.by(() => {
22
+ // scaleSequential/scaleDiverging have .ticks(); scaleLinear does too
23
+ const rawTicks = typeof scale.ticks === 'function'
24
+ ? scale.ticks(tickCount)
25
+ : scale.domain()
26
+ return rawTicks.map((d) => ({ x: scaleTicks(d), label: d }))
27
+ })
28
+
29
+ // Generate gradient stops by sampling the scale at even intervals.
30
+ // Works for scaleSequential, scaleDiverging, and scaleLinear with range().
31
+ const STOP_COUNT = 10
32
+ let stops = $derived.by(() => {
33
+ const domain = scale.domain()
34
+ const min = domain[0]
35
+ const max = domain[domain.length - 1]
36
+ return Array.from({ length: STOP_COUNT + 1 }, (_, i) => {
37
+ const t = i / STOP_COUNT
38
+ const value = min + t * (max - min)
39
+ return { offset: `${(t * 100).toFixed(1)}%`, color: scale(value) }
40
+ })
41
+ })
21
42
  </script>
22
43
 
23
44
  <defs>
24
45
  <linearGradient {id}>
25
- <stop stop-color={colors[0]} offset="0%" />
26
- <stop stop-color={colors[1]} offset="100%" />
46
+ {#each stops as { offset, color } (offset)}
47
+ <stop stop-color={color} {offset} />
48
+ {/each}
27
49
  </linearGradient>
28
50
  </defs>
29
51
  <rect {x} y={y + height} {width} {height} fill="url(#{id})" />
30
- {#each ticks as { x, label }}
52
+ {#each ticks as { x, label }, index (index)}
31
53
  <line x1={x} y1={y + (2 * height) / 3} x2={x} y2={y + height * 2} />
32
54
  <text {x} y={y + height / 2} font-size={textSize}>{label}</text>
33
55
  {/each}
34
- <line x1={x} y1={y + 2 * height} x2={x + 100} y2={y + 2 * height} />
56
+ <line x1={x} y1={y + 2 * height} x2={x + width} y2={y + 2 * height} />
35
57
 
36
58
  <style>
37
59
  line {