@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.
- package/README.md +13 -0
- package/dist/Plot/DataTable.svelte +54 -0
- package/dist/Plot/DataTable.svelte.d.ts +12 -0
- package/dist/Plot/SpecGeoms.svelte +78 -0
- package/dist/Plot/SpecGeoms.svelte.d.ts +11 -0
- package/dist/Plot.svelte +47 -151
- package/dist/Plot.svelte.d.ts +5 -0
- 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/Arc.svelte +1 -1
- package/dist/geoms/Bar.svelte +23 -32
- 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/arc.d.ts +1 -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/brewing/marks/arcs.d.ts +6 -1
- package/dist/lib/brewing/marks/arcs.js +9 -1
- package/dist/lib/brewing/polar.js +8 -3
- 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/spec.d.ts +59 -0
- package/dist/lib/plot/spec.js +131 -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
|
}
|
package/dist/geoms/Arc.svelte
CHANGED
package/dist/geoms/Bar.svelte
CHANGED
|
@@ -91,26 +91,22 @@
|
|
|
91
91
|
const effectiveOrientation = $derived(options.orientation ?? plotState.orientation)
|
|
92
92
|
const seriesField = $derived(fill ?? color)
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
// cf.version is a $state counter that increments on every filter mutation.
|
|
102
|
-
// Reading it here establishes a reactive dependency so the effect re-runs
|
|
103
|
-
// whenever any filter changes — including changes from sibling FilterBars.
|
|
94
|
+
/** Bars the crossfilter dims, by key. A derivation, so it needs no effect writing state. */
|
|
95
|
+
const dimmedByKey = $derived.by((): Record<string, boolean> => {
|
|
96
|
+
if (!cf) return {}
|
|
97
|
+
// cf.version is a $state counter that increments on every filter mutation. Reading it
|
|
98
|
+
// makes this re-derive whenever any filter changes — including a sibling FilterBar's.
|
|
104
99
|
void cf.version
|
|
105
|
-
const
|
|
100
|
+
const dimmed: Record<string, boolean> = {}
|
|
106
101
|
for (const bar of bars) {
|
|
107
|
-
|
|
108
|
-
const dimmedByY = y ? cf.isDimmed(y, bar.data[y]) : false
|
|
109
|
-
next[bar.key] = dimmedByX || dimmedByY
|
|
102
|
+
dimmed[bar.key] = (x ? cf.isDimmed(x, bar.data[x]) : false) || (y ? cf.isDimmed(y, bar.data[y]) : false)
|
|
110
103
|
}
|
|
111
|
-
|
|
104
|
+
return dimmed
|
|
112
105
|
})
|
|
113
106
|
|
|
107
|
+
/** Whether a bar takes clicks and keys at all. */
|
|
108
|
+
const activatable = $derived(filterable || Boolean(onselect) || keyboard || plotState.interactive)
|
|
109
|
+
|
|
114
110
|
function handleBarClick(barX: unknown) {
|
|
115
111
|
if (!filterable || !x || !cf) return
|
|
116
112
|
cf.toggleCategorical(x, barX)
|
|
@@ -130,20 +126,19 @@
|
|
|
130
126
|
)
|
|
131
127
|
}
|
|
132
128
|
|
|
133
|
-
|
|
129
|
+
/**
|
|
130
|
+
* A bar activated by click, Enter or Space. A filterable bar FILTERS — it does not also emit
|
|
131
|
+
* `onselect`, the rule the keyboard path always had and a click did not; otherwise it
|
|
132
|
+
* selects. The plot-level selection (`selectBar`) runs either way.
|
|
133
|
+
*/
|
|
134
|
+
function activate(bar: { data: Row }, event: MouseEvent | KeyboardEvent) {
|
|
134
135
|
if (filterable && x) handleBarClick(bar.data[x])
|
|
135
|
-
onselect?.(bar.data)
|
|
136
|
+
else onselect?.(bar.data)
|
|
136
137
|
selectBar(bar, event)
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
function handleBarKeyActivate(bar: { data: Row }, event: KeyboardEvent) {
|
|
140
|
-
if (event.key
|
|
141
|
-
if (filterable && x) {
|
|
142
|
-
handleBarClick(bar.data[x])
|
|
143
|
-
} else {
|
|
144
|
-
onselect?.(bar.data)
|
|
145
|
-
}
|
|
146
|
-
selectBar(bar, event)
|
|
141
|
+
if (event.key === 'Enter' || event.key === ' ') activate(bar, event)
|
|
147
142
|
}
|
|
148
143
|
</script>
|
|
149
144
|
|
|
@@ -165,14 +160,10 @@
|
|
|
165
160
|
data-plot-category={bar.data[x ?? '']}
|
|
166
161
|
data-dimmed={dimmedByKey[bar.key] ? true : undefined}
|
|
167
162
|
style:cursor={filterable || onselect || plotState.interactive ? 'pointer' : undefined}
|
|
168
|
-
onclick={
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
? (e) => handleBarKeyActivate(bar, e)
|
|
173
|
-
: undefined}
|
|
174
|
-
role={filterable || onselect || keyboard || plotState.interactive ? 'button' : 'graphics-symbol'}
|
|
175
|
-
tabindex={filterable || onselect || keyboard || plotState.interactive ? 0 : undefined}
|
|
163
|
+
onclick={activatable ? (e) => activate(bar, e) : undefined}
|
|
164
|
+
onkeydown={activatable ? (e) => handleBarKeyActivate(bar, e) : undefined}
|
|
165
|
+
role={activatable ? 'button' : 'graphics-symbol'}
|
|
166
|
+
tabindex={activatable ? 0 : undefined}
|
|
176
167
|
use:keyboardNav={keyboard}
|
|
177
168
|
aria-label="{bar.data[x ?? '']}: {bar.data[y ?? '']}"
|
|
178
169
|
onmouseenter={() => plotState.setHovered(bar.data)}
|
|
@@ -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;
|