@rokkit/chart 1.7.0 → 1.8.0

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 (62) hide show
  1. package/README.md +13 -0
  2. package/dist/Plot.svelte +38 -10
  3. package/dist/Plot.svelte.d.ts +7 -2
  4. package/dist/PlotState.svelte.d.ts +115 -71
  5. package/dist/PlotState.svelte.js +174 -547
  6. package/dist/SparkState.svelte.d.ts +3 -1
  7. package/dist/SparkState.svelte.js +11 -41
  8. package/dist/geoms/Contour.svelte +88 -0
  9. package/dist/geoms/Contour.svelte.d.ts +19 -0
  10. package/dist/geoms/Hull.svelte +89 -0
  11. package/dist/geoms/Hull.svelte.d.ts +17 -0
  12. package/dist/geoms/Region.svelte +107 -0
  13. package/dist/geoms/Region.svelte.d.ts +27 -0
  14. package/dist/geoms/Rule.svelte +88 -26
  15. package/dist/geoms/Rule.svelte.d.ts +8 -0
  16. package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
  17. package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
  18. package/dist/geoms/lib/bars.js +13 -20
  19. package/dist/geoms/lib/grouping.d.ts +35 -0
  20. package/dist/geoms/lib/grouping.js +59 -0
  21. package/dist/geoms/lib/marks/contour.d.ts +26 -0
  22. package/dist/geoms/lib/marks/contour.js +67 -0
  23. package/dist/geoms/lib/marks/hull.d.ts +26 -0
  24. package/dist/geoms/lib/marks/hull.js +36 -0
  25. package/dist/geoms/lib/marks/waterfall.js +15 -20
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.js +9 -0
  28. package/dist/lib/abline.d.ts +41 -0
  29. package/dist/lib/abline.js +51 -0
  30. package/dist/lib/hull.d.ts +18 -0
  31. package/dist/lib/hull.js +64 -0
  32. package/dist/lib/plot/domains.d.ts +61 -0
  33. package/dist/lib/plot/domains.js +130 -0
  34. package/dist/lib/plot/running.d.ts +23 -0
  35. package/dist/lib/plot/running.js +32 -0
  36. package/dist/lib/plot/stacking.d.ts +32 -0
  37. package/dist/lib/plot/stacking.js +34 -0
  38. package/dist/lib/plot/types.d.ts +8 -1
  39. package/dist/lib/plot/types.js +5 -1
  40. package/dist/lib/preset.d.ts +2 -0
  41. package/dist/lib/preset.js +76 -41
  42. package/dist/lib/region.d.ts +63 -0
  43. package/dist/lib/region.js +127 -0
  44. package/dist/state/AestheticState.svelte.d.ts +43 -0
  45. package/dist/state/AestheticState.svelte.js +84 -0
  46. package/dist/state/AxisState.svelte.d.ts +26 -0
  47. package/dist/state/AxisState.svelte.js +56 -0
  48. package/dist/state/ChannelState.svelte.d.ts +34 -0
  49. package/dist/state/ChannelState.svelte.js +67 -0
  50. package/dist/state/GeomRegistry.svelte.d.ts +44 -0
  51. package/dist/state/GeomRegistry.svelte.js +86 -0
  52. package/dist/state/InteractionState.svelte.d.ts +30 -0
  53. package/dist/state/InteractionState.svelte.js +69 -0
  54. package/dist/state/OrientationState.svelte.d.ts +34 -0
  55. package/dist/state/OrientationState.svelte.js +64 -0
  56. package/dist/state/PlotConfig.svelte.d.ts +179 -0
  57. package/dist/state/PlotConfig.svelte.js +195 -0
  58. package/dist/state/PlotFrame.svelte.d.ts +17 -0
  59. package/dist/state/PlotFrame.svelte.js +18 -0
  60. package/dist/state/ScaleState.svelte.d.ts +36 -0
  61. package/dist/state/ScaleState.svelte.js +121 -0
  62. package/package.json +5 -3
@@ -111,6 +111,8 @@ export class SparkState {
111
111
  ribbon: number;
112
112
  waterfall: number;
113
113
  rule: number;
114
+ hull: number;
115
+ contour: number;
114
116
  radar: number;
115
117
  };
116
118
  patterns: string[];
@@ -133,6 +135,6 @@ export class SparkState {
133
135
  registerGeom(config: any): string;
134
136
  updateGeom(id: any, config: any): void;
135
137
  unregisterGeom(id: any): void;
136
- geomData(id: any): any[];
138
+ geomData(id: any): Object[];
137
139
  #private;
138
140
  }
@@ -1,12 +1,9 @@
1
- import { untrack } from 'svelte'
2
1
  import { buildUnifiedXScale, buildUnifiedYScale } from './lib/plot/scales.js'
3
2
  import { defaultPreset } from './lib/preset.js'
4
- import { applyGeomStat } from './lib/plot/stat.js'
3
+ import { GeomRegistry } from './state/GeomRegistry.svelte.js'
5
4
  import { distinct, assignColors } from './lib/brewing/colors.js'
6
5
  import { PATTERNS } from './patterns/patterns.js'
7
6
 
8
- let nextGeomId = 0
9
-
10
7
  /**
11
8
  * The full set of members a geom reads off its `'plot-state'` context — derived by
12
9
  * scanning every geom for `plotState.*` / `plot.*` / `state.*` reads (`geoms/*.svelte`,
@@ -117,7 +114,6 @@ export class SparkState {
117
114
  #max = $state(undefined)
118
115
  #baseline = $state(undefined)
119
116
  #pattern = $state(undefined)
120
- #geoms = $state([])
121
117
 
122
118
  // Mirrors Sparkline's current yMin/yMax logic: an explicit min/max wins; otherwise
123
119
  // the domain is the raw data extent, widened to include the baseline when one is
@@ -167,7 +163,12 @@ export class SparkState {
167
163
  return assignColors(values, 'light', defaultPreset)
168
164
  })
169
165
 
166
+ #registry
167
+
170
168
  constructor(config = {}) {
169
+ // The registry reads rows and container channels live, through this state's own
170
+ // `data` / `channels` getters.
171
+ this.#registry = new GeomRegistry(this, 'spark-geom')
171
172
  this.update(config)
172
173
  }
173
174
 
@@ -284,48 +285,17 @@ export class SparkState {
284
285
  clearHovered() {}
285
286
  handleSelect() {}
286
287
 
287
- // Mirrors PlotState.registerGeom at smaller scope: GeomState.register() (called
288
- // from a geom's onMount) calls this once per mounted geom and keeps the returned id.
288
+ // The same registry PlotState composes — one lifecycle, so a fix to it lands in both.
289
289
  registerGeom(config) {
290
- const id = `spark-geom-${nextGeomId++}`
291
- this.#geoms = [...this.#geoms, { id, ...config }]
292
- return id
290
+ return this.#registry.register(config)
293
291
  }
294
-
295
- // untrack the read of #geoms, same as PlotState: GeomState.sync() calls this from
296
- // a geom's `$effect`, which would otherwise track #geoms as a dependency and retrigger
297
- // itself on every update (effect_update_depth_exceeded).
298
292
  updateGeom(id, config) {
299
- this.#geoms = untrack(() => this.#geoms).map((g) => (g.id === id ? { ...g, ...config } : g))
293
+ this.#registry.update(id, config)
300
294
  }
301
-
302
- // No untrack needed here (unlike updateGeom above): this only ever runs from a geom's
303
- // onDestroy, which Svelte always executes outside any reactive tracking scope — there's
304
- // no enclosing effect for a #geoms read to register against, so no self-retrigger risk
305
- // exists. Mirrors PlotState.unregisterGeom, which is untrack-free for the same reason.
306
295
  unregisterGeom(id) {
307
- this.#geoms = this.#geoms.filter((g) => g.id !== id)
296
+ this.#registry.unregister(id)
308
297
  }
309
-
310
- // Identity stat returns `this.#data` itself, not a copy — geoms look up rows via
311
- // `plotState.data.indexOf(row)` (see e.g. Line.svelte's selectPoint), which only
312
- // works if geomData's rows are the exact same object instances as `data`.
313
298
  geomData(id) {
314
- const geom = this.#geoms.find((g) => g.id === id)
315
- if (!geom) return []
316
- const stat = geom.stat ?? 'identity'
317
- if (stat === 'identity') return this.#data
318
- // Strip explicit `undefined` values before spreading: geom components always pass
319
- // every channel key (e.g. Line.svelte's `channels: { x, y, color, fill, symbol }`),
320
- // so a geom that omits x/y to inherit them from the container sends
321
- // `{ x: undefined, y: undefined }`, not `{}`. Left unstripped, that `undefined`
322
- // becomes an own property that overrides the container's value in the spread below,
323
- // silently clobbering an inherited channel (applyGeomStat's primary-key lookup then
324
- // finds nothing and falls back to identity with no warning).
325
- const geomChannels = Object.fromEntries(
326
- Object.entries(geom.channels ?? {}).filter(([, v]) => v !== undefined)
327
- )
328
- const mergedChannels = { ...this.#channels, ...geomChannels }
329
- return applyGeomStat(this.#data, { stat, channels: mergedChannels })
299
+ return this.#registry.data(id)
330
300
  }
331
301
  }
@@ -0,0 +1,88 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Kernel-density contours — where points pile up, drawn as nested rings (or filled bands).
4
+ *
5
+ * A scatter of a few hundred modules hides its density: on a main-sequence plot most of
6
+ * them sit on the A = 0 edge on top of each other. Contours show the pile-up without
7
+ * binning it into cells the way `Hexbin` does, and they overlay the points instead of
8
+ * replacing them. Grouped by `fill` (else `color`), each group contours separately.
9
+ */
10
+ import { getContext, onMount, onDestroy } from 'svelte'
11
+ import type { PlotState } from '../PlotState.svelte.js'
12
+ import { GeomState } from './lib/GeomState.svelte.js'
13
+ import { buildContourMarks } from './lib/marks/contour.js'
14
+ import PlotAreaClip from './lib/PlotAreaClip.svelte'
15
+
16
+ type Props = {
17
+ x?: string
18
+ y?: string
19
+ /** Group field (and its colour). Wins over `color`. */
20
+ fill?: string
21
+ /** Group field (and its colour) when `fill` is not set. */
22
+ color?: string
23
+ /** Kernel bandwidth in pixels — larger is smoother. */
24
+ bandwidth?: number
25
+ /** Approximate number of density levels. */
26
+ thresholds?: number
27
+ /** Fill the bands instead of stroking the rings. */
28
+ filled?: boolean
29
+ /** Opacity of the densest band when filled, or of every ring when stroked. */
30
+ alpha?: number
31
+ }
32
+
33
+ let { x, y, fill, color, bandwidth = 20, thresholds = 8, filled = false, alpha }: Props = $props()
34
+
35
+ const plotState = getContext<PlotState>('plot-state')
36
+ const uid = $props.id()
37
+ const clipId = `plot-contour-clip-${uid}`
38
+ const geom = new GeomState(plotState, () => ({
39
+ type: 'contour',
40
+ channels: { x, y, fill, color },
41
+ options: { bandwidth, thresholds },
42
+ alpha,
43
+ build: buildContourMarks
44
+ }))
45
+ onMount(geom.register)
46
+ onDestroy(geom.destroy)
47
+ $effect(geom.sync)
48
+
49
+ const rings = $derived(geom.marks)
50
+ const grouped = $derived(Boolean(fill ?? color))
51
+ </script>
52
+
53
+ {#if rings.length > 0}
54
+ <PlotAreaClip id={clipId} />
55
+ <g
56
+ data-plot-geom="contour"
57
+ data-plot-contour-mode={filled ? 'filled' : 'lines'}
58
+ clip-path="url(#{clipId})"
59
+ >
60
+ {#each rings as r (r.key)}
61
+ <path
62
+ d={r.d}
63
+ fill={filled ? r.fill : 'none'}
64
+ fill-opacity={filled ? r.fillAlpha : undefined}
65
+ stroke={filled ? 'none' : grouped ? r.stroke : undefined}
66
+ stroke-opacity={!filled && alpha !== undefined ? alpha : undefined}
67
+ data-plot-element="contour"
68
+ data-plot-contour={r.group}
69
+ data-plot-contour-level={r.level}
70
+ />
71
+ {/each}
72
+ </g>
73
+ {/if}
74
+
75
+ <style>
76
+ [data-plot-geom='contour'] {
77
+ pointer-events: none;
78
+ }
79
+ [data-plot-contour-mode='lines'] [data-plot-element='contour'] {
80
+ stroke-width: var(--chart-contour-width, 1);
81
+ }
82
+ [data-plot-contour-mode='lines'] [data-plot-element='contour']:not([stroke-opacity]) {
83
+ stroke-opacity: var(--chart-contour-opacity, 0.7);
84
+ }
85
+ [data-plot-contour-mode='lines'] [data-plot-element='contour']:not([stroke]) {
86
+ stroke: var(--chart-contour-color, currentColor);
87
+ }
88
+ </style>
@@ -0,0 +1,19 @@
1
+ type Props = {
2
+ x?: string;
3
+ y?: string;
4
+ /** Group field (and its colour). Wins over `color`. */
5
+ fill?: string;
6
+ /** Group field (and its colour) when `fill` is not set. */
7
+ color?: string;
8
+ /** Kernel bandwidth in pixels — larger is smoother. */
9
+ bandwidth?: number;
10
+ /** Approximate number of density levels. */
11
+ thresholds?: number;
12
+ /** Fill the bands instead of stroking the rings. */
13
+ filled?: boolean;
14
+ /** Opacity of the densest band when filled, or of every ring when stroked. */
15
+ alpha?: number;
16
+ };
17
+ declare const Contour: import("svelte").Component<Props, {}, "">;
18
+ type Contour = ReturnType<typeof Contour>;
19
+ export default Contour;
@@ -0,0 +1,89 @@
1
+ <script lang="ts">
2
+ /**
3
+ * An outline around each group of points — the convex hull of the `fill` (else `color`)
4
+ * field's rows, padded so the outline clears the points it encloses.
5
+ *
6
+ * Answers "how spread out is this group": a package whose modules span the zone of pain to
7
+ * the zone of uselessness has a hull the size of the chart. The group field joins the
8
+ * shared palette, so a hull and the points it encloses take the same colour — or, with
9
+ * different fields, hulls group by package while points colour by community.
10
+ *
11
+ * Place it BEFORE the points so the fill sits behind them.
12
+ */
13
+ import { getContext, onMount, onDestroy } from 'svelte'
14
+ import type { PlotState } from '../PlotState.svelte.js'
15
+ import { GeomState } from './lib/GeomState.svelte.js'
16
+ import { buildHullMarks } from './lib/marks/hull.js'
17
+
18
+ type Props = {
19
+ x?: string
20
+ y?: string
21
+ /** Group field (and its colour). Wins over `color`. */
22
+ fill?: string
23
+ /** Group field (and its colour) when `fill` is not set. */
24
+ color?: string
25
+ /** Pixels between the outermost points and the outline. */
26
+ padding?: number
27
+ /** Opacity 0–1 of the whole hull; defaults to the preset's `hull` opacity. */
28
+ alpha?: number
29
+ /** Draw the group's name at its centroid. */
30
+ label?: boolean
31
+ }
32
+
33
+ let { x, y, fill, color, padding = 10, alpha, label = false }: Props = $props()
34
+
35
+ const plotState = getContext<PlotState>('plot-state')
36
+ const geom = new GeomState(plotState, () => ({
37
+ type: 'hull',
38
+ channels: { x, y, fill, color },
39
+ alpha,
40
+ build: buildHullMarks
41
+ }))
42
+ onMount(geom.register)
43
+ onDestroy(geom.destroy)
44
+ $effect(geom.sync)
45
+
46
+ const hulls = $derived(geom.marks)
47
+ </script>
48
+
49
+ {#if hulls.length > 0}
50
+ <g data-plot-geom="hull">
51
+ {#each hulls as h (h.key)}
52
+ <!-- `opacity`, not fill-/stroke-opacity: the padding is a stroke that overlaps the
53
+ fill, and only element opacity composites the two as one shape. -->
54
+ <path
55
+ d={h.d}
56
+ fill={h.fill}
57
+ stroke={h.fill}
58
+ stroke-width={padding * 2}
59
+ stroke-linejoin="round"
60
+ stroke-linecap="round"
61
+ opacity={h.alpha}
62
+ data-plot-element="hull"
63
+ data-plot-hull={h.group}
64
+ />
65
+ {/each}
66
+ {#if label}
67
+ {#each hulls as h (h.key)}
68
+ <text
69
+ x={h.at.x}
70
+ y={h.at.y}
71
+ text-anchor="middle"
72
+ dominant-baseline="middle"
73
+ data-plot-element="hull-label">{h.group}</text
74
+ >
75
+ {/each}
76
+ {/if}
77
+ </g>
78
+ {/if}
79
+
80
+ <style>
81
+ [data-plot-geom='hull'] {
82
+ pointer-events: none;
83
+ }
84
+ [data-plot-element='hull-label'] {
85
+ fill: var(--chart-hull-label-color, currentColor);
86
+ font-size: var(--chart-hull-label-size, 11px);
87
+ font-weight: 600;
88
+ }
89
+ </style>
@@ -0,0 +1,17 @@
1
+ type Props = {
2
+ x?: string;
3
+ y?: string;
4
+ /** Group field (and its colour). Wins over `color`. */
5
+ fill?: string;
6
+ /** Group field (and its colour) when `fill` is not set. */
7
+ color?: string;
8
+ /** Pixels between the outermost points and the outline. */
9
+ padding?: number;
10
+ /** Opacity 0–1 of the whole hull; defaults to the preset's `hull` opacity. */
11
+ alpha?: number;
12
+ /** Draw the group's name at its centroid. */
13
+ label?: boolean;
14
+ };
15
+ declare const Hull: import("svelte").Component<Props, {}, "">;
16
+ type Hull = ReturnType<typeof Hull>;
17
+ export default Hull;
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A shaded region in DATA coordinates — a band (`x` and/or `y` as `[lo, hi]`) or a polygon
4
+ * (`points`).
5
+ *
6
+ * The zones on a main-sequence diagram, the quadrants of a hotspot map, the threshold box
7
+ * of a code-smell detection rule: each is a statement about the metric PLANE, not about
8
+ * any row, so a region reads no data and registers no geom. It never moves the scales —
9
+ * declare `xDomain`/`yDomain` on the chart when the region, not the data, defines the frame.
10
+ *
11
+ * SVG paints in document order, so place a Region BEFORE the marks it sits behind.
12
+ */
13
+ import { getContext } from 'svelte'
14
+ import type { PlotState } from '../PlotState.svelte.js'
15
+ import { regionVertices, positionOf, centroid, polygonPath } from '../lib/region.js'
16
+ import PlotAreaClip from './lib/PlotAreaClip.svelte'
17
+
18
+ type Value = number | string
19
+ type Props = {
20
+ /**
21
+ * `[lo, hi]` on the x axis. Omit to span the whole axis; a `null` end runs to that edge,
22
+ * so `[p75, null]` is "the top quartile and beyond". A band category covers its band.
23
+ */
24
+ x?: [Value | null, Value | null]
25
+ /** `[lo, hi]` on the y axis, with the same open-end rule. */
26
+ y?: [Value | null, Value | null]
27
+ /** Polygon vertices as `[x, y]` pairs in data coordinates. Wins over `x`/`y`. */
28
+ points?: Array<[Value, Value]>
29
+ /** Theming hook — rendered as `data-plot-region="<name>"`. */
30
+ name?: string
31
+ /** Text drawn at the region's centroid (or at `labelAt`). */
32
+ label?: string
33
+ /** `[x, y]` in data coordinates for the label, when the centroid is the wrong spot. */
34
+ labelAt?: [Value, Value]
35
+ /** Literal fill colour. Defaults to `--chart-region-fill`. */
36
+ fill?: string
37
+ /** Fill opacity 0–1. Defaults to `--chart-region-opacity`. */
38
+ alpha?: number
39
+ /** Optional outline colour. */
40
+ stroke?: string
41
+ }
42
+
43
+ let {
44
+ x = undefined,
45
+ y = undefined,
46
+ points = undefined,
47
+ name = undefined,
48
+ label = undefined,
49
+ labelAt = undefined,
50
+ fill = undefined,
51
+ alpha = undefined,
52
+ stroke = undefined
53
+ }: Props = $props()
54
+
55
+ const plotState = getContext<PlotState>('plot-state')
56
+ const uid = $props.id()
57
+ const clipId = `plot-region-clip-${uid}`
58
+
59
+ const shape = $derived.by(() => {
60
+ const xs = plotState.xScale
61
+ const ys = plotState.yScale
62
+ if (!xs || !ys) return null
63
+ const uv = regionVertices({ x, y, points }, xs, ys)
64
+ if (uv.length === 0) return null
65
+ const pts = uv.map(([u, v]) => plotState.place(u, v))
66
+ const at = labelAt
67
+ ? plotState.place(positionOf(xs, labelAt[0]), positionOf(ys, labelAt[1]))
68
+ : centroid(pts)
69
+ return { d: polygonPath(pts), at }
70
+ })
71
+ </script>
72
+
73
+ {#if shape}
74
+ <PlotAreaClip id={clipId} />
75
+ <g data-plot-geom="region" data-plot-region={name} clip-path="url(#{clipId})">
76
+ <path d={shape.d} {fill} fill-opacity={alpha} {stroke} data-plot-element="region" />
77
+ {#if label}
78
+ <text
79
+ x={shape.at.x}
80
+ y={shape.at.y}
81
+ text-anchor="middle"
82
+ dominant-baseline="middle"
83
+ data-plot-element="region-label">{label}</text
84
+ >
85
+ {/if}
86
+ </g>
87
+ {/if}
88
+
89
+ <style>
90
+ [data-plot-geom='region'] {
91
+ pointer-events: none;
92
+ }
93
+ [data-plot-element='region']:not([fill]) {
94
+ fill: var(--chart-region-fill, currentColor);
95
+ }
96
+ [data-plot-element='region']:not([fill-opacity]) {
97
+ fill-opacity: var(--chart-region-opacity, 0.08);
98
+ }
99
+ [data-plot-element='region']:not([stroke]) {
100
+ stroke: none;
101
+ }
102
+ [data-plot-element='region-label'] {
103
+ fill: var(--chart-region-label-color, currentColor);
104
+ font-size: var(--chart-region-label-size, 11px);
105
+ opacity: var(--chart-region-label-opacity, 0.75);
106
+ }
107
+ </style>
@@ -0,0 +1,27 @@
1
+ type Value = number | string;
2
+ type Props = {
3
+ /**
4
+ * `[lo, hi]` on the x axis. Omit to span the whole axis; a `null` end runs to that edge,
5
+ * so `[p75, null]` is "the top quartile and beyond". A band category covers its band.
6
+ */
7
+ x?: [Value | null, Value | null];
8
+ /** `[lo, hi]` on the y axis, with the same open-end rule. */
9
+ y?: [Value | null, Value | null];
10
+ /** Polygon vertices as `[x, y]` pairs in data coordinates. Wins over `x`/`y`. */
11
+ points?: Array<[Value, Value]>;
12
+ /** Theming hook — rendered as `data-plot-region="<name>"`. */
13
+ name?: string;
14
+ /** Text drawn at the region's centroid (or at `labelAt`). */
15
+ label?: string;
16
+ /** `[x, y]` in data coordinates for the label, when the centroid is the wrong spot. */
17
+ labelAt?: [Value, Value];
18
+ /** Literal fill colour. Defaults to `--chart-region-fill`. */
19
+ fill?: string;
20
+ /** Fill opacity 0–1. Defaults to `--chart-region-opacity`. */
21
+ alpha?: number;
22
+ /** Optional outline colour. */
23
+ stroke?: string;
24
+ };
25
+ declare const Region: import("svelte").Component<Props, {}, "">;
26
+ type Region = ReturnType<typeof Region>;
27
+ export default Region;
@@ -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;