@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.
- package/README.md +13 -0
- package/dist/Plot.svelte +38 -10
- package/dist/Plot.svelte.d.ts +7 -2
- package/dist/PlotState.svelte.d.ts +115 -71
- package/dist/PlotState.svelte.js +174 -547
- package/dist/SparkState.svelte.d.ts +3 -1
- package/dist/SparkState.svelte.js +11 -41
- package/dist/geoms/Contour.svelte +88 -0
- package/dist/geoms/Contour.svelte.d.ts +19 -0
- package/dist/geoms/Hull.svelte +89 -0
- package/dist/geoms/Hull.svelte.d.ts +17 -0
- package/dist/geoms/Region.svelte +107 -0
- package/dist/geoms/Region.svelte.d.ts +27 -0
- package/dist/geoms/Rule.svelte +88 -26
- package/dist/geoms/Rule.svelte.d.ts +8 -0
- package/dist/geoms/lib/PlotAreaClip.svelte +18 -0
- package/dist/geoms/lib/PlotAreaClip.svelte.d.ts +6 -0
- package/dist/geoms/lib/bars.js +13 -20
- package/dist/geoms/lib/grouping.d.ts +35 -0
- package/dist/geoms/lib/grouping.js +59 -0
- package/dist/geoms/lib/marks/contour.d.ts +26 -0
- package/dist/geoms/lib/marks/contour.js +67 -0
- package/dist/geoms/lib/marks/hull.d.ts +26 -0
- package/dist/geoms/lib/marks/hull.js +36 -0
- package/dist/geoms/lib/marks/waterfall.js +15 -20
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/lib/abline.d.ts +41 -0
- package/dist/lib/abline.js +51 -0
- package/dist/lib/hull.d.ts +18 -0
- package/dist/lib/hull.js +64 -0
- package/dist/lib/plot/domains.d.ts +61 -0
- package/dist/lib/plot/domains.js +130 -0
- package/dist/lib/plot/running.d.ts +23 -0
- package/dist/lib/plot/running.js +32 -0
- package/dist/lib/plot/stacking.d.ts +32 -0
- package/dist/lib/plot/stacking.js +34 -0
- package/dist/lib/plot/types.d.ts +8 -1
- package/dist/lib/plot/types.js +5 -1
- package/dist/lib/preset.d.ts +2 -0
- package/dist/lib/preset.js +76 -41
- package/dist/lib/region.d.ts +63 -0
- package/dist/lib/region.js +127 -0
- package/dist/state/AestheticState.svelte.d.ts +43 -0
- package/dist/state/AestheticState.svelte.js +84 -0
- package/dist/state/AxisState.svelte.d.ts +26 -0
- package/dist/state/AxisState.svelte.js +56 -0
- package/dist/state/ChannelState.svelte.d.ts +34 -0
- package/dist/state/ChannelState.svelte.js +67 -0
- package/dist/state/GeomRegistry.svelte.d.ts +44 -0
- package/dist/state/GeomRegistry.svelte.js +86 -0
- package/dist/state/InteractionState.svelte.d.ts +30 -0
- package/dist/state/InteractionState.svelte.js +69 -0
- package/dist/state/OrientationState.svelte.d.ts +34 -0
- package/dist/state/OrientationState.svelte.js +64 -0
- package/dist/state/PlotConfig.svelte.d.ts +179 -0
- package/dist/state/PlotConfig.svelte.js +195 -0
- package/dist/state/PlotFrame.svelte.d.ts +17 -0
- package/dist/state/PlotFrame.svelte.js +18 -0
- package/dist/state/ScaleState.svelte.d.ts +36 -0
- package/dist/state/ScaleState.svelte.js +121 -0
- 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):
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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;
|
package/dist/geoms/Rule.svelte
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
>
|
|
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>
|