@rokkit/chart 1.7.0 → 1.8.1

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 (74) hide show
  1. package/README.md +13 -0
  2. package/dist/Plot/DataTable.svelte +54 -0
  3. package/dist/Plot/DataTable.svelte.d.ts +12 -0
  4. package/dist/Plot/SpecGeoms.svelte +78 -0
  5. package/dist/Plot/SpecGeoms.svelte.d.ts +11 -0
  6. package/dist/Plot.svelte +47 -151
  7. package/dist/Plot.svelte.d.ts +5 -0
  8. package/dist/PlotState.svelte.d.ts +115 -71
  9. package/dist/PlotState.svelte.js +174 -547
  10. package/dist/SparkState.svelte.d.ts +3 -1
  11. package/dist/SparkState.svelte.js +11 -41
  12. package/dist/geoms/Arc.svelte +1 -1
  13. package/dist/geoms/Bar.svelte +23 -32
  14. package/dist/geoms/Contour.svelte +88 -0
  15. package/dist/geoms/Contour.svelte.d.ts +19 -0
  16. package/dist/geoms/Hull.svelte +89 -0
  17. package/dist/geoms/Hull.svelte.d.ts +17 -0
  18. package/dist/geoms/Region.svelte +107 -0
  19. package/dist/geoms/Region.svelte.d.ts +27 -0
  20. package/dist/geoms/Rule.svelte +88 -26
  21. package/dist/geoms/Rule.svelte.d.ts +8 -0
  22. package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
  23. package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
  24. package/dist/geoms/lib/bars.js +13 -20
  25. package/dist/geoms/lib/grouping.d.ts +35 -0
  26. package/dist/geoms/lib/grouping.js +59 -0
  27. package/dist/geoms/lib/marks/arc.d.ts +1 -0
  28. package/dist/geoms/lib/marks/contour.d.ts +26 -0
  29. package/dist/geoms/lib/marks/contour.js +67 -0
  30. package/dist/geoms/lib/marks/hull.d.ts +26 -0
  31. package/dist/geoms/lib/marks/hull.js +36 -0
  32. package/dist/geoms/lib/marks/waterfall.js +15 -20
  33. package/dist/index.d.ts +9 -0
  34. package/dist/index.js +9 -0
  35. package/dist/lib/abline.d.ts +41 -0
  36. package/dist/lib/abline.js +51 -0
  37. package/dist/lib/brewing/marks/arcs.d.ts +6 -1
  38. package/dist/lib/brewing/marks/arcs.js +9 -1
  39. package/dist/lib/brewing/polar.js +8 -3
  40. package/dist/lib/hull.d.ts +18 -0
  41. package/dist/lib/hull.js +64 -0
  42. package/dist/lib/plot/domains.d.ts +61 -0
  43. package/dist/lib/plot/domains.js +130 -0
  44. package/dist/lib/plot/running.d.ts +23 -0
  45. package/dist/lib/plot/running.js +32 -0
  46. package/dist/lib/plot/spec.d.ts +59 -0
  47. package/dist/lib/plot/spec.js +131 -0
  48. package/dist/lib/plot/stacking.d.ts +32 -0
  49. package/dist/lib/plot/stacking.js +34 -0
  50. package/dist/lib/plot/types.d.ts +8 -1
  51. package/dist/lib/plot/types.js +5 -1
  52. package/dist/lib/preset.d.ts +2 -0
  53. package/dist/lib/preset.js +76 -41
  54. package/dist/lib/region.d.ts +63 -0
  55. package/dist/lib/region.js +127 -0
  56. package/dist/state/AestheticState.svelte.d.ts +43 -0
  57. package/dist/state/AestheticState.svelte.js +84 -0
  58. package/dist/state/AxisState.svelte.d.ts +26 -0
  59. package/dist/state/AxisState.svelte.js +56 -0
  60. package/dist/state/ChannelState.svelte.d.ts +34 -0
  61. package/dist/state/ChannelState.svelte.js +67 -0
  62. package/dist/state/GeomRegistry.svelte.d.ts +44 -0
  63. package/dist/state/GeomRegistry.svelte.js +86 -0
  64. package/dist/state/InteractionState.svelte.d.ts +30 -0
  65. package/dist/state/InteractionState.svelte.js +69 -0
  66. package/dist/state/OrientationState.svelte.d.ts +34 -0
  67. package/dist/state/OrientationState.svelte.js +64 -0
  68. package/dist/state/PlotConfig.svelte.d.ts +179 -0
  69. package/dist/state/PlotConfig.svelte.js +195 -0
  70. package/dist/state/PlotFrame.svelte.d.ts +17 -0
  71. package/dist/state/PlotFrame.svelte.js +18 -0
  72. package/dist/state/ScaleState.svelte.d.ts +36 -0
  73. package/dist/state/ScaleState.svelte.js +121 -0
  74. package/package.json +5 -3
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { getContext } from 'svelte'
3
3
  import type { PlotState } from '../PlotState.svelte.js'
4
+ import { clipAbline, slopeLabel } from '../lib/abline.js'
4
5
 
5
6
  type RuleValue = number | string
6
7
  type Props = {
@@ -8,6 +9,14 @@
8
9
  x?: RuleValue | RuleValue[]
9
10
  /** Horizontal reference line(s) at these y-axis value(s). */
10
11
  y?: RuleValue | RuleValue[]
12
+ /**
13
+ * A sloped line `y = slope·x + intercept` (ggplot's `abline`), clipped to the visible
14
+ * domain. Continuous axes only — a slope over categories has no meaning, so it is
15
+ * ignored on a band axis. The main sequence is `slope={-1} intercept={1}`.
16
+ */
17
+ slope?: number
18
+ /** Where the sloped line crosses x = 0. Defaults to 0. */
19
+ intercept?: number
11
20
  /** Line color (aligns with the other geoms' `color` aesthetic). */
12
21
  color?: string
13
22
  /** @deprecated use `color` — kept as an alias. */
@@ -22,6 +31,8 @@
22
31
  let {
23
32
  x,
24
33
  y,
34
+ slope = undefined,
35
+ intercept = 0,
25
36
  color = undefined,
26
37
  stroke = undefined,
27
38
  dash = '4 4',
@@ -37,7 +48,10 @@
37
48
  v === undefined ? [] : Array.isArray(v) ? v : [v]
38
49
 
39
50
  // Position along an axis: band scales resolve to the band centre, linear scales map directly.
40
- function axisPos(scale: { (v: unknown): number | undefined; bandwidth?: () => number }, v: unknown) {
51
+ function axisPos(
52
+ scale: { (v: unknown): number | undefined; bandwidth?: () => number },
53
+ v: unknown
54
+ ) {
41
55
  const base = scale(v) ?? 0
42
56
  return typeof scale.bandwidth === 'function' ? base + scale.bandwidth() / 2 : base
43
57
  }
@@ -50,37 +64,72 @@
50
64
  const labelFor = (v: RuleValue) =>
51
65
  typeof label === 'function' ? label(v) : typeof label === 'string' ? label : null
52
66
 
67
+ type Point = { x: number; y: number }
68
+ type RuleLine = {
69
+ key: string
70
+ x1: number
71
+ y1: number
72
+ x2: number
73
+ y2: number
74
+ value: RuleValue
75
+ text: string | null
76
+ kind: 'x' | 'y' | 'slope'
77
+ }
78
+
79
+ const lineOf = (id: Pick<RuleLine, 'key' | 'kind' | 'value'>, a: Point, b: Point): RuleLine => ({
80
+ ...id,
81
+ x1: a.x,
82
+ y1: a.y,
83
+ x2: b.x,
84
+ y2: b.y,
85
+ text: labelFor(id.value)
86
+ })
87
+
88
+ type AnyScale = {
89
+ (v: unknown): number | undefined
90
+ bandwidth?: () => number
91
+ domain: () => unknown[]
92
+ }
93
+ const isBand = (scale: AnyScale) => typeof scale.bandwidth === 'function'
94
+
95
+ // The sloped line, clipped in DATA space so it stays inside the plot at any zoom. A band
96
+ // axis has no numeric domain to clip against, so the slope is ignored there.
97
+ function slopeLine(
98
+ xs: AnyScale,
99
+ ys: AnyScale,
100
+ place: (u: number, v: number) => Point
101
+ ): RuleLine | null {
102
+ if (slope === undefined || isBand(xs) || isBand(ys)) return null
103
+ const seg = clipAbline(
104
+ slope,
105
+ intercept,
106
+ xs.domain() as [number, number],
107
+ ys.domain() as [number, number]
108
+ )
109
+ if (!seg) return null
110
+ const a = place(Number(xs(seg.x1)), Number(ys(seg.y1)))
111
+ const b = place(Number(xs(seg.x2)), Number(ys(seg.y2)))
112
+ return lineOf({ key: `slope-${slope}-${intercept}`, kind: 'slope', value: slope }, a, b)
113
+ }
114
+
53
115
  // Each rule is expressed in (x-channel, y-channel) space and placed to screen, so it
54
116
  // transposes under orientation flip: a y-value reference is horizontal when vertical and
55
117
  // vertical when flipped (it stays perpendicular to the value axis).
56
- const rules = $derived.by(() => {
118
+ const rules = $derived.by((): RuleLine[] => {
57
119
  if (!xScale || !yScale) return []
58
120
  const place = plotState.place.bind(plotState)
59
- const out: Array<{
60
- key: string
61
- x1: number
62
- y1: number
63
- x2: number
64
- y2: number
65
- value: RuleValue
66
- text: string | null
67
- }> = []
68
121
  const [xLo, xHi] = axisExtent(xScale)
69
122
  const [yLo, yHi] = axisExtent(yScale)
70
-
71
- for (const yv of toArray(y)) {
123
+ const horizontal = toArray(y).map((yv) => {
72
124
  const yc = axisPos(yScale, yv)
73
- const a = place(xLo, yc)
74
- const b = place(xHi, yc)
75
- out.push({ key: `y-${yv}`, x1: a.x, y1: a.y, x2: b.x, y2: b.y, value: yv, text: labelFor(yv) })
76
- }
77
- for (const xv of toArray(x)) {
125
+ return lineOf({ key: `y-${yv}`, kind: 'y', value: yv }, place(xLo, yc), place(xHi, yc))
126
+ })
127
+ const vertical = toArray(x).map((xv) => {
78
128
  const xc = axisPos(xScale, xv)
79
- const a = place(xc, yLo)
80
- const b = place(xc, yHi)
81
- out.push({ key: `x-${xv}`, x1: a.x, y1: a.y, x2: b.x, y2: b.y, value: xv, text: labelFor(xv) })
82
- }
83
- return out
129
+ return lineOf({ key: `x-${xv}`, kind: 'x', value: xv }, place(xc, yLo), place(xc, yHi))
130
+ })
131
+ const sloped = slopeLine(xScale, yScale, place)
132
+ return sloped ? [...horizontal, ...vertical, sloped] : [...horizontal, ...vertical]
84
133
  })
85
134
 
86
135
  const strokeColor = $derived(color ?? stroke ?? 'currentColor')
@@ -98,9 +147,22 @@
98
147
  stroke-width={strokeWidth}
99
148
  stroke-dasharray={dash || undefined}
100
149
  data-plot-element="rule"
150
+ data-plot-rule={rule.kind}
101
151
  data-plot-value={rule.value}
102
152
  />
103
- {#if rule.text}
153
+ {#if rule.text && rule.kind === 'slope'}
154
+ {@const at = slopeLabel(rule)}
155
+ <text
156
+ x={at.x}
157
+ y={at.y}
158
+ dy="-5"
159
+ text-anchor="middle"
160
+ transform="rotate({at.angle} {at.x} {at.y})"
161
+ font-size="10"
162
+ fill={strokeColor}
163
+ data-plot-element="rule-label">{rule.text}</text
164
+ >
165
+ {:else if rule.text}
104
166
  <text
105
167
  x={rule.x2}
106
168
  y={rule.y2}
@@ -109,8 +171,8 @@
109
171
  text-anchor="end"
110
172
  font-size="10"
111
173
  fill={strokeColor}
112
- data-plot-element="rule-label"
113
- >{rule.text}</text>
174
+ data-plot-element="rule-label">{rule.text}</text
175
+ >
114
176
  {/if}
115
177
  {/each}
116
178
  </g>
@@ -4,6 +4,14 @@ type Props = {
4
4
  x?: RuleValue | RuleValue[];
5
5
  /** Horizontal reference line(s) at these y-axis value(s). */
6
6
  y?: RuleValue | RuleValue[];
7
+ /**
8
+ * A sloped line `y = slope·x + intercept` (ggplot's `abline`), clipped to the visible
9
+ * domain. Continuous axes only — a slope over categories has no meaning, so it is
10
+ * ignored on a band axis. The main sequence is `slope={-1} intercept={1}`.
11
+ */
12
+ slope?: number;
13
+ /** Where the sloped line crosses x = 0. Defaults to 0. */
14
+ intercept?: number;
7
15
  /** Line color (aligns with the other geoms' `color` aesthetic). */
8
16
  color?: string;
9
17
  /** @deprecated use `color` — kept as an alias. */
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A clip path the size of the plot area, for geoms whose shapes can legitimately extend
4
+ * past the domain — a region polygon drawn beyond the axes, a density ring whose kernel
5
+ * reaches past the last point. Clipped, they stop at the axes instead of painting over them.
6
+ */
7
+ import { getContext } from 'svelte'
8
+ import type { PlotState } from '../../PlotState.svelte.js'
9
+
10
+ let { id }: { id: string } = $props()
11
+ const plotState = getContext<PlotState>('plot-state')
12
+ </script>
13
+
14
+ <defs>
15
+ <clipPath {id}>
16
+ <rect x="0" y="0" width={plotState.innerWidth} height={plotState.innerHeight} />
17
+ </clipPath>
18
+ </defs>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ id: string;
3
+ };
4
+ declare const PlotAreaClip: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type PlotAreaClip = ReturnType<typeof PlotAreaClip>;
6
+ export default PlotAreaClip;
@@ -1,7 +1,8 @@
1
1
  import { scaleBand } from 'd3-scale'
2
2
  import { stack, stackOffsetExpand } from 'd3-shape'
3
3
  import { toPatternId } from '../../lib/brewing/patterns.js'
4
- import { isLiteralColor, literalColor, markEntry } from '../../lib/brewing/colors.js'
4
+ import { literalColor, markEntry } from '../../lib/brewing/colors.js'
5
+ import { subBandFields } from '../../lib/plot/stacking.js'
5
6
 
6
7
  /**
7
8
  * Returns a band scale suitable for bar x-positioning.
@@ -16,24 +17,6 @@ function ensureBandX(xScale, data, xField) {
16
17
  return scaleBand().domain(domain).range([r0, r1]).padding(0.2)
17
18
  }
18
19
 
19
- /**
20
- * Returns the sub-band fields: distinct non-x fields among [group, color, pattern].
21
- * These are the fields that cause multiple bars within a single x-band (dodge/grouping).
22
- * `group` (the explicit grouping channel) takes precedence as the first sub-band.
23
- */
24
- function subBandFields(channels) {
25
- const { x: xf, group: gf, color: cf, pattern: pf } = channels
26
- const seen = new Set()
27
- const out = []
28
- for (const f of [gf, cf, pf]) {
29
- if (f && f !== xf && !seen.has(f) && !isLiteralColor(f)) {
30
- seen.add(f)
31
- out.push(f)
32
- }
33
- }
34
- return out
35
- }
36
-
37
20
  /**
38
21
  * The one bar builder. Expresses each bar in abstract (category, value) space and maps it to
39
22
  * screen via `place()`, so it renders vertical or horizontal (flip) with the same code.
@@ -141,7 +124,17 @@ export function buildBars(
141
124
  })
142
125
  }
143
126
 
144
- export function buildStackedBars(data, channels, xScale, yScale, colors, innerHeight, patterns, place = (x, y) => ({ x, y }), normalize = false) {
127
+ export function buildStackedBars(
128
+ data,
129
+ channels,
130
+ xScale,
131
+ yScale,
132
+ colors,
133
+ innerHeight,
134
+ patterns,
135
+ place = (x, y) => ({ x, y }),
136
+ normalize = false
137
+ ) {
145
138
  const { x: xf, y: yf, color: cf, pattern: pf } = channels
146
139
  const lit = literalColor(cf)
147
140
  const field = lit ? undefined : cf
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared by the geoms that summarise GROUPS of points (Hull, Contour) rather than drawing one
3
+ * mark per row: bucket rows by the group field, and place each row on screen — or drop it
4
+ * when it has no usable coordinate, which is a normal state in a partially-measured dataset.
5
+ */
6
+ /**
7
+ * Screen position of one value on a scale, or null when the row has no usable coordinate
8
+ * (null / empty / a category the scale does not know / NaN). A band category resolves to its
9
+ * centre, where a point in that category is drawn.
10
+ *
11
+ * @param {any} scale
12
+ * @param {unknown} v
13
+ * @returns {number | null}
14
+ */
15
+ export function coordOf(scale: any, v: unknown): number | null;
16
+ /**
17
+ * Rows bucketed by `field`, in first-seen order. One bucket (key `null`) without a field.
18
+ *
19
+ * @param {Record<string, unknown>[]} data
20
+ * @param {string | undefined} field
21
+ * @returns {Map<unknown, Record<string, unknown>[]>}
22
+ */
23
+ export function groupRows(data: Record<string, unknown>[], field: string | undefined): Map<unknown, Record<string, unknown>[]>;
24
+ /**
25
+ * Screen points for rows, after orientation `place`, skipping rows with no coordinate.
26
+ *
27
+ * @param {Record<string, unknown>[]} rows
28
+ * @param {any} plot
29
+ * @param {{ x?: string, y?: string }} channels
30
+ * @returns {Array<[number, number]>}
31
+ */
32
+ export function screenPoints(rows: Record<string, unknown>[], plot: any, channels: {
33
+ x?: string;
34
+ y?: string;
35
+ }): Array<[number, number]>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared by the geoms that summarise GROUPS of points (Hull, Contour) rather than drawing one
3
+ * mark per row: bucket rows by the group field, and place each row on screen — or drop it
4
+ * when it has no usable coordinate, which is a normal state in a partially-measured dataset.
5
+ */
6
+
7
+ /**
8
+ * Screen position of one value on a scale, or null when the row has no usable coordinate
9
+ * (null / empty / a category the scale does not know / NaN). A band category resolves to its
10
+ * centre, where a point in that category is drawn.
11
+ *
12
+ * @param {any} scale
13
+ * @param {unknown} v
14
+ * @returns {number | null}
15
+ */
16
+ export function coordOf(scale, v) {
17
+ if (v === null || v === undefined || v === '') return null
18
+ const s = scale(v)
19
+ if (s === null || s === undefined || Number.isNaN(s)) return null
20
+ return typeof scale.bandwidth === 'function' ? s + scale.bandwidth() / 2 : s
21
+ }
22
+
23
+ /**
24
+ * Rows bucketed by `field`, in first-seen order. One bucket (key `null`) without a field.
25
+ *
26
+ * @param {Record<string, unknown>[]} data
27
+ * @param {string | undefined} field
28
+ * @returns {Map<unknown, Record<string, unknown>[]>}
29
+ */
30
+ export function groupRows(data, field) {
31
+ const groups = new Map()
32
+ for (const row of data) {
33
+ const key = field ? row[field] : null
34
+ if (!groups.has(key)) groups.set(key, [])
35
+ groups.get(key).push(row)
36
+ }
37
+ return groups
38
+ }
39
+
40
+ /**
41
+ * Screen points for rows, after orientation `place`, skipping rows with no coordinate.
42
+ *
43
+ * @param {Record<string, unknown>[]} rows
44
+ * @param {any} plot
45
+ * @param {{ x?: string, y?: string }} channels
46
+ * @returns {Array<[number, number]>}
47
+ */
48
+ export function screenPoints(rows, plot, channels) {
49
+ /** @type {Array<[number, number]>} */
50
+ const pts = []
51
+ for (const row of rows) {
52
+ const u = coordOf(plot.xScale, row[channels.x ?? ''])
53
+ const v = coordOf(plot.yScale, row[channels.y ?? ''])
54
+ if (u === null || v === null) continue
55
+ const p = plot.place(u, v)
56
+ pts.push([p.x, p.y])
57
+ }
58
+ return pts
59
+ }
@@ -13,6 +13,7 @@ export function buildArcMarks({ data, plot, channels, options, alpha, type }: {
13
13
  type?: string;
14
14
  }): {
15
15
  alpha: number;
16
+ id: string;
16
17
  d: string | null;
17
18
  fill: string;
18
19
  stroke: string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SVG path data for a GeoJSON MultiPolygon's coordinates — every ring of every polygon a
3
+ * closed subpath, so holes cut out under the default nonzero/evenodd rules d3-contour's
4
+ * winding produces.
5
+ *
6
+ * @param {number[][][][]} coordinates
7
+ */
8
+ export function multiPolygonPath(coordinates: number[][][][]): string;
9
+ /**
10
+ * Kernel-density contours per group of the `fill` (else `color`) field.
11
+ *
12
+ * Density is estimated in SCREEN space over the plot area, so `bandwidth` is in pixels — the
13
+ * unit a reader judges smoothness in — and the result is the same at any data scale. Level 0
14
+ * is the outermost (lowest-density) ring; filled bands ramp opacity toward `alpha` at the
15
+ * densest core, so pile-ups read darkest.
16
+ *
17
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
18
+ */
19
+ export function buildContourMarks({ data, plot, channels, options, alpha, type }: {
20
+ data: any[];
21
+ plot: any;
22
+ channels: any;
23
+ options?: any;
24
+ alpha?: number;
25
+ type?: string;
26
+ }): any[];
@@ -0,0 +1,67 @@
1
+ import { contourDensity } from 'd3-contour'
2
+ import { resolveFillStroke, resolveAlpha } from '../aesthetics.js'
3
+ import { groupRows, screenPoints } from '../grouping.js'
4
+
5
+ /**
6
+ * SVG path data for a GeoJSON MultiPolygon's coordinates — every ring of every polygon a
7
+ * closed subpath, so holes cut out under the default nonzero/evenodd rules d3-contour's
8
+ * winding produces.
9
+ *
10
+ * @param {number[][][][]} coordinates
11
+ */
12
+ export function multiPolygonPath(coordinates) {
13
+ const fmt = (/** @type {number} */ n) => Math.round(n * 100) / 100
14
+ return coordinates
15
+ .flatMap((polygon) =>
16
+ polygon.map((ring) => `M${ring.map(([x, y]) => `${fmt(x)},${fmt(y)}`).join('L')}Z`)
17
+ )
18
+ .join('')
19
+ }
20
+
21
+ /**
22
+ * Kernel-density contours per group of the `fill` (else `color`) field.
23
+ *
24
+ * Density is estimated in SCREEN space over the plot area, so `bandwidth` is in pixels — the
25
+ * unit a reader judges smoothness in — and the result is the same at any data scale. Level 0
26
+ * is the outermost (lowest-density) ring; filled bands ramp opacity toward `alpha` at the
27
+ * densest core, so pile-ups read darkest.
28
+ *
29
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
30
+ */
31
+ export function buildContourMarks({ data, plot, channels, options = {}, alpha, type = 'contour' }) {
32
+ if (!data?.length || !plot.xScale || !plot.yScale) return []
33
+ const groupField = channels.fill ?? channels.color
34
+ const a = resolveAlpha(alpha, type, plot.chartPreset)
35
+ const w = Math.max(1, Math.round(plot.innerWidth))
36
+ const h = Math.max(1, Math.round(plot.innerHeight))
37
+
38
+ const marks = []
39
+ for (const [key, rows] of groupRows(data, groupField)) {
40
+ const pts = screenPoints(rows, plot, channels)
41
+ if (pts.length === 0) continue
42
+ const density = contourDensity()
43
+ .x((d) => d[0])
44
+ .y((d) => d[1])
45
+ .size([w, h])
46
+ .bandwidth(options.bandwidth ?? 20)
47
+ .thresholds(options.thresholds ?? 8)(pts)
48
+ const { fill, stroke } = resolveFillStroke(
49
+ rows[0],
50
+ { fill: channels.fill, color: channels.color },
51
+ plot.colors
52
+ )
53
+ density.forEach((level, i) => {
54
+ marks.push({
55
+ key: `contour-${String(key)}-${i}`,
56
+ group: key,
57
+ level: i,
58
+ value: level.value,
59
+ d: multiPolygonPath(level.coordinates),
60
+ fill,
61
+ stroke,
62
+ fillAlpha: (a * (i + 1)) / density.length
63
+ })
64
+ })
65
+ }
66
+ return marks
67
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * One outline per group of the `fill` (else `color`) channel — ggplot's discrete-colour
3
+ * grouping. The hull is computed in SCREEN space after `place`, so it is convex as drawn,
4
+ * whatever the scales (a hull in data space is not convex on a log axis).
5
+ *
6
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
7
+ */
8
+ export function buildHullMarks({ data, plot, channels, alpha, type }: {
9
+ data: any[];
10
+ plot: any;
11
+ channels: any;
12
+ options?: any;
13
+ alpha?: number;
14
+ type?: string;
15
+ }): {
16
+ key: string;
17
+ group: unknown;
18
+ d: string;
19
+ fill: string;
20
+ alpha: number;
21
+ at: {
22
+ x: number;
23
+ y: number;
24
+ };
25
+ count: number;
26
+ }[];
@@ -0,0 +1,36 @@
1
+ import { convexHull, hullPath } from '../../../lib/hull.js'
2
+ import { centroid } from '../../../lib/region.js'
3
+ import { resolveFillStroke, resolveAlpha } from '../aesthetics.js'
4
+ import { groupRows, screenPoints } from '../grouping.js'
5
+
6
+ /**
7
+ * One outline per group of the `fill` (else `color`) channel — ggplot's discrete-colour
8
+ * grouping. The hull is computed in SCREEN space after `place`, so it is convex as drawn,
9
+ * whatever the scales (a hull in data space is not convex on a log axis).
10
+ *
11
+ * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
12
+ */
13
+ export function buildHullMarks({ data, plot, channels, alpha, type = 'hull' }) {
14
+ const { xScale, yScale, colors } = plot
15
+ if (!data?.length || !xScale || !yScale) return []
16
+ const groupField = channels.fill ?? channels.color
17
+ const a = resolveAlpha(alpha, type, plot.chartPreset)
18
+
19
+ const marks = []
20
+ for (const [key, rows] of groupRows(data, groupField)) {
21
+ const pts = screenPoints(rows, plot, channels)
22
+ if (pts.length === 0) continue
23
+ const hull = convexHull(pts)
24
+ const { fill } = resolveFillStroke(rows[0], { fill: groupField }, colors)
25
+ marks.push({
26
+ key: `hull-${String(key)}`,
27
+ group: key,
28
+ d: hullPath(hull),
29
+ fill,
30
+ alpha: a,
31
+ at: centroid(hull.map(([x, y]) => ({ x, y }))),
32
+ count: rows.length
33
+ })
34
+ }
35
+ return marks
36
+ }
@@ -1,11 +1,19 @@
1
1
  import { resolveAlpha } from '../aesthetics.js'
2
+ import { runningSpans } from '../../../lib/plot/running.js'
2
3
 
3
4
  /**
4
5
  * Build renderable waterfall bars. Bar color is semantic (positive/negative/total from
5
6
  * options), not the shared palette. Adds fixed `alpha` and carries connector anchors.
6
7
  * @param {{ data: any[], plot: any, channels: any, options?: any, alpha?: number, type?: string }} ctx
7
8
  */
8
- export function buildWaterfallMarks({ data, plot, channels, options = {}, alpha, type = 'waterfall' }) {
9
+ export function buildWaterfallMarks({
10
+ data,
11
+ plot,
12
+ channels,
13
+ options = {},
14
+ alpha,
15
+ type = 'waterfall'
16
+ }) {
9
17
  const { xScale, yScale } = plot
10
18
  if (!data?.length || !xScale || !yScale) return []
11
19
 
@@ -16,28 +24,15 @@ export function buildWaterfallMarks({ data, plot, channels, options = {}, alpha,
16
24
  const a = resolveAlpha(alpha, type, plot.chartPreset)
17
25
 
18
26
  const bw = typeof xScale.bandwidth === 'function' ? xScale.bandwidth() : 10
19
- let cumulative = 0
27
+ const spans = runningSpans(data, channels.y ?? '', totalField)
20
28
 
21
29
  return data.map((d, i) => {
22
30
  const xVal = d[channels.x ?? '']
23
- const yVal = Number(d[channels.y ?? ''])
24
- const isTotal = totalField ? Boolean(d[totalField]) : false
31
+ const { lo, hi, total, isTotal, delta } = spans[i]
25
32
  const xPos = xScale(xVal) ?? 0
26
- let barTop
27
- let barBottom
28
- let fill
29
-
30
- if (isTotal) {
31
- barTop = yScale(Math.max(0, cumulative)) ?? 0
32
- barBottom = yScale(0) ?? 0
33
- fill = totalColor
34
- } else {
35
- const start = cumulative
36
- cumulative += yVal
37
- barTop = yScale(Math.max(start, cumulative)) ?? 0
38
- barBottom = yScale(Math.min(start, cumulative)) ?? 0
39
- fill = yVal >= 0 ? positiveColor : negativeColor
40
- }
33
+ const barTop = yScale(hi) ?? 0
34
+ const barBottom = yScale(lo) ?? 0
35
+ const fill = isTotal ? totalColor : delta >= 0 ? positiveColor : negativeColor
41
36
 
42
37
  const c1 = plot.place(xPos, barTop)
43
38
  const c2 = plot.place(xPos + bw, barBottom)
@@ -51,7 +46,7 @@ export function buildWaterfallMarks({ data, plot, channels, options = {}, alpha,
51
46
  alpha: a,
52
47
  bandStart: xPos,
53
48
  bandEnd: xPos + bw,
54
- cumY: yScale(cumulative) ?? 0,
49
+ cumY: yScale(total) ?? 0,
55
50
  data: d
56
51
  }
57
52
  })
package/dist/index.d.ts CHANGED
@@ -19,6 +19,9 @@ export namespace Plot {
19
19
  export { Ribbon };
20
20
  export { Waterfall };
21
21
  export { Rule };
22
+ export { Region };
23
+ export { Hull };
24
+ export { Contour };
22
25
  export { Radar };
23
26
  }
24
27
  export namespace PlotLayers { }
@@ -45,6 +48,9 @@ export { default as GeomHighlight } from "./geoms/Highlight.svelte";
45
48
  export { default as GeomTrend } from "./geoms/Trend.svelte";
46
49
  export { default as GeomJitter } from "./geoms/Jitter.svelte";
47
50
  export { default as GeomRule } from "./geoms/Rule.svelte";
51
+ export { default as GeomRegion } from "./geoms/Region.svelte";
52
+ export { default as GeomHull } from "./geoms/Hull.svelte";
53
+ export { default as GeomContour } from "./geoms/Contour.svelte";
48
54
  export { default as Chart } from "./Chart.svelte";
49
55
  export { default as Sparkline } from "./Sparkline.svelte";
50
56
  export { default as Spark } from "./Spark.svelte";
@@ -83,6 +89,9 @@ import Hexbin from './geoms/Hexbin.svelte';
83
89
  import Ribbon from './geoms/Ribbon.svelte';
84
90
  import Waterfall from './geoms/Waterfall.svelte';
85
91
  import Rule from './geoms/Rule.svelte';
92
+ import Region from './geoms/Region.svelte';
93
+ import Hull from './geoms/Hull.svelte';
94
+ import Contour from './geoms/Contour.svelte';
86
95
  import Radar from './geoms/Radar.svelte';
87
96
  export { createChartPreset, defaultPreset } from "./lib/preset.js";
88
97
  export { SparkState, GEOM_CONTRACT } from "./SparkState.svelte.js";
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ import Hexbin from './geoms/Hexbin.svelte'
18
18
  import Ribbon from './geoms/Ribbon.svelte'
19
19
  import Waterfall from './geoms/Waterfall.svelte'
20
20
  import Rule from './geoms/Rule.svelte'
21
+ import Region from './geoms/Region.svelte'
22
+ import Hull from './geoms/Hull.svelte'
23
+ import Contour from './geoms/Contour.svelte'
21
24
  import Radar from './geoms/Radar.svelte'
22
25
 
23
26
  // Composable Plot primitives — use as <Plot.Root>, <Plot.Axis>, <Plot.Bar>, etc.
@@ -42,6 +45,9 @@ export const Plot = {
42
45
  Ribbon,
43
46
  Waterfall,
44
47
  Rule,
48
+ Region,
49
+ Hull,
50
+ Contour,
45
51
  Radar
46
52
  }
47
53
 
@@ -77,6 +83,9 @@ export { default as GeomHighlight } from './geoms/Highlight.svelte'
77
83
  export { default as GeomTrend } from './geoms/Trend.svelte'
78
84
  export { default as GeomJitter } from './geoms/Jitter.svelte'
79
85
  export { default as GeomRule } from './geoms/Rule.svelte'
86
+ export { default as GeomRegion } from './geoms/Region.svelte'
87
+ export { default as GeomHull } from './geoms/Hull.svelte'
88
+ export { default as GeomContour } from './geoms/Contour.svelte'
80
89
 
81
90
  // Export standalone components
82
91
  export { default as Chart } from './Chart.svelte'