From e9835972cd1f64709682a620f1edfb5d3130e419 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 4 Aug 2026 13:36:47 -0400 Subject: [PATCH 1/7] feat(Voronoi): Add `children` snippet exposing per-cell geometry (`point`, `polygon`, `centroid`, `area`) for custom rendering (e.g. labels). --- .changeset/voronoi-cell-children.md | 5 + docs/src/content/components/Voronoi.md | 20 ++- .../examples/components/Voronoi/labels.svelte | 90 ++++++++++++++ packages/layerchart/package.json | 2 + .../components/Voronoi/Voronoi.base.svelte | 115 +++++++++++++----- .../Voronoi/Voronoi.shared.svelte.ts | 35 ++++++ pnpm-lock.yaml | 27 +++- 7 files changed, 264 insertions(+), 30 deletions(-) create mode 100644 .changeset/voronoi-cell-children.md create mode 100644 docs/src/examples/components/Voronoi/labels.svelte diff --git a/.changeset/voronoi-cell-children.md b/.changeset/voronoi-cell-children.md new file mode 100644 index 000000000..d5592746e --- /dev/null +++ b/.changeset/voronoi-cell-children.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Voronoi): Add `children` snippet exposing per-cell geometry (`point`, `polygon`, `centroid`, `area`) for custom rendering (e.g. labels). diff --git a/docs/src/content/components/Voronoi.md b/docs/src/content/components/Voronoi.md index b4a83d0bb..35719d65c 100644 --- a/docs/src/content/components/Voronoi.md +++ b/docs/src/content/components/Voronoi.md @@ -2,9 +2,27 @@ description: Interaction component which creates Voronoi diagrams to divide a plane according to the nearest points, aiding spatial analysis and visualization. category: interactions layers: [svg, canvas] -related: [TooltipContext] +related: [TooltipContext, AnnotationPoint] --- ## Usage :example{ name="radius" showCode } + +## Labels + +Use the `children` snippet to render custom content from the computed cell +geometry. Each cell provides its `data`, `point`, `polygon`, `centroid`, and +`area`, which can be used to place non-overlapping labels — orienting each label +towards the open space of its cell (the centroid) and hiding labels for crowded +cells. + +:example{ name="labels" showCode } + +### Geographic + +Within a geo ``, the same cell geometry is available (projected to pixel +space), which can place map labels away from their point and towards the open +space of each cell. + +:example{ name="geo-labels" showCode } diff --git a/docs/src/examples/components/Voronoi/labels.svelte b/docs/src/examples/components/Voronoi/labels.svelte new file mode 100644 index 000000000..cfd0c882d --- /dev/null +++ b/docs/src/examples/components/Voronoi/labels.svelte @@ -0,0 +1,90 @@ + + +
+ + + + + + +
+ + + + + {#snippet children({ cells })} + + {@const avgArea = cells.reduce((sum, c) => sum + c.area, 0) / cells.length} + {@const areaThreshold = avgArea * 0.72} + + {#if showCentroidLines} + {#each cells as cell (cell.index)} + {#if cell.centroid} + + {/if} + {/each} + {/if} + + {#each cells as cell (cell.index)} + + {/each} + + {#each cells as cell (cell.index)} + {#if cell.centroid && cell.area > areaThreshold} + {@const [px, py] = cell.point} + {@const [cx, cy] = cell.centroid} + {@const angle = (Math.round((Math.atan2(cy - py, cx - px) / Math.PI) * 2) + 4) % 4} + {@const o = orient[angle]} + + {/if} + {/each} + {/snippet} + + + diff --git a/packages/layerchart/package.json b/packages/layerchart/package.json index fa9fcee4e..a2d940ec0 100644 --- a/packages/layerchart/package.json +++ b/packages/layerchart/package.json @@ -47,6 +47,7 @@ "@types/d3-interpolate": "^3.0.4", "@types/d3-interpolate-path": "^2.0.3", "@types/d3-path": "^3.1.1", + "@types/d3-polygon": "^3.0.2", "@types/d3-quadtree": "^3.0.6", "@types/d3-random": "^3.0.3", "@types/d3-sankey": "^0.12.5", @@ -93,6 +94,7 @@ "d3-interpolate": "^3.0.1", "d3-interpolate-path": "^2.3.0", "d3-path": "^3.1.0", + "d3-polygon": "^3.0.1", "d3-quadtree": "^3.0.1", "d3-random": "^3.0.1", "d3-sankey": "^0.12.3", diff --git a/packages/layerchart/src/lib/components/Voronoi/Voronoi.base.svelte b/packages/layerchart/src/lib/components/Voronoi/Voronoi.base.svelte index 5bc5aef01..f51ca06d4 100644 --- a/packages/layerchart/src/lib/components/Voronoi/Voronoi.base.svelte +++ b/packages/layerchart/src/lib/components/Voronoi/Voronoi.base.svelte @@ -14,8 +14,10 @@ {#if geo.projection} - {@const polygons = geoVoronoi().polygons(points)} - {#each polygons.features as feature} - {@const point = r ? geo.projection?.(feature.properties.sitecoordinates) : null} - - - onclick?.(e, { data: feature.properties.site.data, feature })} - onpointerenter={(e: PointerEvent) => - onpointerenter?.(e, { data: feature.properties.site.data, feature })} - onpointermove={(e: PointerEvent) => - onpointermove?.(e, { data: feature.properties.site.data, feature })} - onpointerdown={(e: PointerEvent) => - onpointerdown?.(e, { data: feature.properties.site.data, feature })} - ontouchmove={(e: TouchEvent) => { - e.preventDefault(); - }} - /> - - {/each} - {:else} - {@const voronoi = Delaunay.from(points).voronoi([0, 0, boundWidth, boundHeight])} + {#if geoPolygons} + {#each geoPolygons.features as feature} + {@const point = r ? geo.projection?.(feature.properties.sitecoordinates) : null} + + + onclick?.(e, { data: feature.properties.site.data, feature })} + onpointerenter={(e: PointerEvent) => + onpointerenter?.(e, { data: feature.properties.site.data, feature })} + onpointermove={(e: PointerEvent) => + onpointermove?.(e, { data: feature.properties.site.data, feature })} + onpointerdown={(e: PointerEvent) => + onpointerdown?.(e, { data: feature.properties.site.data, feature })} + ontouchmove={(e: TouchEvent) => { + e.preventDefault(); + }} + /> + + {/each} + {/if} + {:else if voronoi} {#each points as point, i} {@const pathData = voronoi.renderCell(i)} {#if pathData} @@ -139,6 +196,8 @@ {/if} {/each} {/if} + + {@render children?.({ cells })} From 6e1f1d6fd62e735fd703aecf60a0941c83008533 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 5 Aug 2026 20:21:20 -0400 Subject: [PATCH 3/7] feat(Labels): Add `layout="voronoi"` to place each label in its Voronoi cell's open space, with `occlude` to drop overlaps and `links` to move labels out with a leader line back to the point (scatter/map "smart labels"), and make the `fontSize` prop take effect. --- .changeset/labels-voronoi-layout.md | 5 + .changeset/occlude-util.md | 5 + .changeset/point-label-placement.md | 5 + .changeset/points-geo.md | 5 + docs/src/content/components/Labels.md | 14 ++ docs/src/content/components/Voronoi.md | 18 --- .../components/Labels/voronoi-geo.svelte | 104 ++++++++++++ .../examples/components/Labels/voronoi.svelte | 80 +++++++++ .../components/Voronoi/geo-labels.svelte | 131 --------------- .../examples/components/Voronoi/labels.svelte | 90 ----------- .../AnnotationPoint.base.svelte | 97 +++-------- .../lib/components/Labels/Labels.base.svelte | 93 +++++++++-- .../components/Labels/Labels.canvas.svelte | 3 +- .../components/Labels/Labels.shared.svelte.ts | 153 +++++++++++++++++- .../lib/components/Labels/Labels.svg.svelte | 3 +- .../components/Points/Points.shared.svelte.ts | 18 +++ packages/layerchart/src/lib/utils/index.ts | 3 + .../src/lib/utils/labelPlacement.ts | 135 ++++++++++++++++ .../layerchart/src/lib/utils/occlusion.ts | 58 +++++++ packages/layerchart/src/lib/utils/string.ts | 35 ++++ 20 files changed, 722 insertions(+), 333 deletions(-) create mode 100644 .changeset/labels-voronoi-layout.md create mode 100644 .changeset/occlude-util.md create mode 100644 .changeset/point-label-placement.md create mode 100644 .changeset/points-geo.md create mode 100644 docs/src/examples/components/Labels/voronoi-geo.svelte create mode 100644 docs/src/examples/components/Labels/voronoi.svelte delete mode 100644 docs/src/examples/components/Voronoi/geo-labels.svelte delete mode 100644 docs/src/examples/components/Voronoi/labels.svelte create mode 100644 packages/layerchart/src/lib/utils/labelPlacement.ts create mode 100644 packages/layerchart/src/lib/utils/occlusion.ts diff --git a/.changeset/labels-voronoi-layout.md b/.changeset/labels-voronoi-layout.md new file mode 100644 index 000000000..75ddf6696 --- /dev/null +++ b/.changeset/labels-voronoi-layout.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Labels): Add `layout="voronoi"` to place each label in its Voronoi cell's open space, with `occlude` to drop overlaps and `links` to move labels out with a leader line back to the point (scatter/map "smart labels"), and make the `fontSize` prop take effect. diff --git a/.changeset/occlude-util.md b/.changeset/occlude-util.md new file mode 100644 index 000000000..2e715d9d4 --- /dev/null +++ b/.changeset/occlude-util.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(utils): Add `occlude()` for greedy non-overlapping label placement, plus `getTextRect()` (and now-exported `getStringWidth()`) to measure a text label's box the same way `` does — a convenient `bounds` for `occlude()`. diff --git a/.changeset/point-label-placement.md b/.changeset/point-label-placement.md new file mode 100644 index 000000000..663584f4c --- /dev/null +++ b/.changeset/point-label-placement.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(utils): Add `getPointLabelLayout()` / `getPointLabelRect()` to resolve a point label's placement and bounding box (`smart` or discrete) — the shared geometry behind `` and ``, and a convenient `bounds` for `occlude()`. diff --git a/.changeset/points-geo.md b/.changeset/points-geo.md new file mode 100644 index 000000000..a3bb81692 --- /dev/null +++ b/.changeset/points-geo.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Points | Labels): Project through the chart's geo projection when present, so `` (and ``) render on maps. diff --git a/docs/src/content/components/Labels.md b/docs/src/content/components/Labels.md index 374cc41ce..2696556b1 100644 --- a/docs/src/content/components/Labels.md +++ b/docs/src/content/components/Labels.md @@ -51,6 +51,20 @@ Series end labels can be shown for multi-series line charts, and highlighted on :example{ component="LineChart" name="series-labels-hover" } +### Scatter plots + +Use `layout="voronoi"` to orient each label towards the open space of its Voronoi +cell, `occlude` to hide labels that would overlap a roomier-cell neighbor, and +`links` to move each label out into open space with a leader line back to the point +(d3-ring-note / smart-labels style) — no per-point placement setup required. + +:example{ name="voronoi" showCode } + +It works on maps too — in a geo ``, `` and the labels project through +the projection, so the same props place airport labels in open space with leaders. + +:example{ name="voronoi-geo" showCode } + ### Simplified charts Labels are also integrated in simplified charts via the `labels` prop diff --git a/docs/src/content/components/Voronoi.md b/docs/src/content/components/Voronoi.md index 35719d65c..00a4727b0 100644 --- a/docs/src/content/components/Voronoi.md +++ b/docs/src/content/components/Voronoi.md @@ -8,21 +8,3 @@ related: [TooltipContext, AnnotationPoint] ## Usage :example{ name="radius" showCode } - -## Labels - -Use the `children` snippet to render custom content from the computed cell -geometry. Each cell provides its `data`, `point`, `polygon`, `centroid`, and -`area`, which can be used to place non-overlapping labels — orienting each label -towards the open space of its cell (the centroid) and hiding labels for crowded -cells. - -:example{ name="labels" showCode } - -### Geographic - -Within a geo ``, the same cell geometry is available (projected to pixel -space), which can place map labels away from their point and towards the open -space of each cell. - -:example{ name="geo-labels" showCode } diff --git a/docs/src/examples/components/Labels/voronoi-geo.svelte b/docs/src/examples/components/Labels/voronoi-geo.svelte new file mode 100644 index 000000000..73a79e612 --- /dev/null +++ b/docs/src/examples/components/Labels/voronoi-geo.svelte @@ -0,0 +1,104 @@ + + + + +
+ + +
e.stopPropagation()} + role="none" + > + +
+
+ +
+ + +
+
+
+ + + + + {#each countries.features as country} + + {/each} + + + d.name.split(' ')[0]} + layout="voronoi" + links={useLinks ? { type: linkType, class: 'stroke-surface-content/40' } : false} + occlude={occludeLabels ? { padding: spacing } : false} + fontSize={9} + class="fill-surface-content stroke-surface-100 stroke-[3px] [paint-order:stroke] pointer-events-none" + /> + + diff --git a/docs/src/examples/components/Labels/voronoi.svelte b/docs/src/examples/components/Labels/voronoi.svelte new file mode 100644 index 000000000..76c4472bf --- /dev/null +++ b/docs/src/examples/components/Labels/voronoi.svelte @@ -0,0 +1,80 @@ + + +
+ +
e.stopPropagation()} + role="none" + > + +
+
+ +
+ + +
+
+ + + +
+ + + + {#if showVoronoi} + + {/if} + + d.i} + layout="voronoi" + links={useLinks ? { type: linkType, class: 'stroke-surface-content/40' } : false} + occlude={occludeLabels ? { padding: spacing } : false} + fontSize={10} + class="fill-surface-content pointer-events-none" + /> + + diff --git a/docs/src/examples/components/Voronoi/geo-labels.svelte b/docs/src/examples/components/Voronoi/geo-labels.svelte deleted file mode 100644 index 03f43e476..000000000 --- a/docs/src/examples/components/Voronoi/geo-labels.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - - -
- - - - - - - - -
- - - {#snippet children({ context })} - - - {#each countries.features as country} - - {/each} - - - {#snippet children({ cells })} - - {@const maxMove = context.width * 0.2} - {@const placed = cells.map((cell) => { - const move = - moveToCentroids && - cell.centroid != null && - Number.isFinite(cell.point[0]) && - Math.hypot(cell.centroid[0] - cell.point[0], cell.centroid[1] - cell.point[1]) <= - maxMove; - // Move the label to the (nearby) cell centroid; keep it at the point otherwise - const anchor = move ? cell.centroid! : cell.point; - return { cell, label: cell.data.name.split(' ')[0], anchor, move }; - })} - - {#each placed as { cell, label, anchor, move } (cell.index)} - {#if Number.isFinite(cell.point[0])} - - {/if} - {/each} - {/snippet} - - - {/snippet} - diff --git a/docs/src/examples/components/Voronoi/labels.svelte b/docs/src/examples/components/Voronoi/labels.svelte deleted file mode 100644 index cfd0c882d..000000000 --- a/docs/src/examples/components/Voronoi/labels.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - -
- - - - - - -
- - - - - {#snippet children({ cells })} - - {@const avgArea = cells.reduce((sum, c) => sum + c.area, 0) / cells.length} - {@const areaThreshold = avgArea * 0.72} - - {#if showCentroidLines} - {#each cells as cell (cell.index)} - {#if cell.centroid} - - {/if} - {/each} - {/if} - - {#each cells as cell (cell.index)} - - {/each} - - {#each cells as cell (cell.index)} - {#if cell.centroid && cell.area > areaThreshold} - {@const [px, py] = cell.point} - {@const [cx, cy] = cell.centroid} - {@const angle = (Math.round((Math.atan2(cy - py, cx - px) / Math.PI) * 2) + 4) % 4} - {@const o = orient[angle]} - - {/if} - {/each} - {/snippet} - - - diff --git a/packages/layerchart/src/lib/components/AnnotationPoint/AnnotationPoint.base.svelte b/packages/layerchart/src/lib/components/AnnotationPoint/AnnotationPoint.base.svelte index 099ea1a54..1b902155a 100644 --- a/packages/layerchart/src/lib/components/AnnotationPoint/AnnotationPoint.base.svelte +++ b/packages/layerchart/src/lib/components/AnnotationPoint/AnnotationPoint.base.svelte @@ -20,6 +20,7 @@ import { getChartContext } from '$lib/contexts/chart.js'; import { getGeoContext } from '$lib/contexts/geo.js'; import { isScaleBand } from '$lib/utils/scales.svelte.js'; + import { getPointLabelLayout } from '$lib/utils/labelPlacement.js'; import { getPixelValue } from '../Text/Text.shared.svelte.js'; import { cls } from '@layerstack/tailwind'; @@ -59,79 +60,27 @@ }; }); - const labelLayout = $derived.by(() => { - const px = point.x; - const py = point.y; - const explicit = labelX != null || labelY != null; - const capHeight = getPixelValue(fontSize) * 0.71; - - // Direction from the point towards the label. `smart` derives it from the - // geometry (snapped to the 8 cardinal/diagonal directions); otherwise it - // comes from the discrete placement. - let dirX = 0; - let dirY = 0; - if (labelPlacement === 'smart') { - const ddx = (labelX ?? px) - px; - const ddy = (labelY ?? py) - py; - const ax = Math.abs(ddx); - const ay = Math.abs(ddy); - if (ax > 1e-6 || ay > 1e-6) { - dirX = ax >= ay * 0.4 ? Math.sign(ddx) : 0; - dirY = ay >= ax * 0.4 ? Math.sign(ddy) : 0; - } - } else if (labelPlacement !== 'center') { - dirX = labelPlacement.includes('left') ? -1 : labelPlacement.includes('right') ? 1 : 0; - dirY = labelPlacement.includes('top') ? -1 : labelPlacement.includes('bottom') ? 1 : 0; - } - - const mag = Math.hypot(dirX, dirY) || 1; - const signX = dirX < 0 ? -1 : 1; - const signY = dirY < 0 ? -1 : 1; - - // The link connects the ring to this anchor — either an explicit - // `labelX`/`labelY`, or offset from the point in the direction. - const anchorX = explicit ? (labelX ?? px) : px + (r * dirX) / mag + labelXOffset * signX; - const anchorY = explicit ? (labelY ?? py) : py + (r * dirY) / mag + labelYOffset * signY; - - // When there's a leader line, nudge the text away from the point (along the - // line) by `labelGap` to leave spacing — the line itself is unchanged. - const gap = link ? labelGap : 0; - const adx = anchorX - px; - const ady = anchorY - py; - const adist = Math.hypot(adx, ady) || 1; - const gapX = (gap * adx) / adist; - const gapY = (gap * ady) / adist; - - // Bias by half the cap height so the near edge (not the center) sits at the - // (gap-adjusted) anchor — keeps top/bottom symmetric for any fontSize. Skip - // it when the caller sets an explicit `verticalAnchor` (they control it). - const capBias = - props?.label?.verticalAnchor != null - ? 0 - : dirY > 0 - ? capHeight / 2 - : dirY < 0 - ? -capHeight / 2 - : 0; - - return { - dirX, - dirY, - anchor: { x: anchorX, y: anchorY }, - text: { - x: anchorX + gapX, - y: anchorY + gapY + capBias, - textAnchor: (dirX > 0 ? 'start' : dirX < 0 ? 'end' : 'middle') as - | 'start' - | 'end' - | 'middle', - verticalAnchor: 'middle' as const, - fontSize, - }, - }; - }); - - const labelProps = $derived(labelLayout.text); + // Where `smart`/discrete placement puts the label — shared with `getPointLabelRect` + // consumers (e.g. occlusion) so the measured box matches the rendered label. + const labelLayout = $derived( + getPointLabelLayout({ + x: point.x, + y: point.y, + r, + labelPlacement, + labelX, + labelY, + labelXOffset, + labelYOffset, + fontSize: getPixelValue(fontSize), + labelGap, + link: !!link, + verticalAnchor: props?.label?.verticalAnchor, + }) + ); + + // Render `` with the raw `fontSize` (it resolves em/etc. itself) + const labelProps = $derived({ ...labelLayout.text, fontSize }); // Leader `` endpoints. The target is the anchor; the source sits on the // ring — following the label for `smart`, but fixed to the placement direction @@ -152,7 +101,7 @@ }; } - const { dirX, dirY } = labelLayout; + const { x: dirX, y: dirY } = labelLayout.direction; if (dirX === 0 && dirY === 0) return null; // labelPlacement='center' — no line const mag = Math.hypot(dirX, dirY); return { diff --git a/packages/layerchart/src/lib/components/Labels/Labels.base.svelte b/packages/layerchart/src/lib/components/Labels/Labels.base.svelte index 4301a897c..cc0f1c246 100644 --- a/packages/layerchart/src/lib/components/Labels/Labels.base.svelte +++ b/packages/layerchart/src/lib/components/Labels/Labels.base.svelte @@ -6,26 +6,34 @@ Text: Component; Group: Component; Points: Component; + /** Leader lines for `layout="voronoi"` with `links`. Omitted on the HTML layer. */ + Link?: Component; }; export type LabelsBaseProps = LabelsProps & LabelsBaseLayerComponents; - + {#snippet children({ points }: { points: Point[] })} - {#each points as point, i (key(point.data, i))} - {@const baseProps = c.getTextProps(point, points, i)} - {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')} - {#if childrenProp} - {@render childrenProp({ data: point, textProps })} - {:else} - - {/if} - {/each} + {#if layout === 'voronoi'} + {@const voronoiLabels = c.getVoronoiLabels(points)} + {#each points as point, i (key(point.data, i))} + {@const item = voronoiLabels[i]} + {#if item.visible} + {@const textProps = extractLayerProps(item.textProps, 'lc-labels-text')} + {#if childrenProp} + {@render childrenProp({ data: point, textProps, link: item.link })} + {:else} + {#if item.link && Link} + + {/if} + + {/if} + {/if} + {/each} + {:else} + {#each points as point, i (key(point.data, i))} + {@const baseProps = c.getTextProps(point, points, i)} + {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')} + {#if childrenProp} + {@render childrenProp({ data: point, textProps })} + {:else} + + {/if} + {/each} + {/if} {/snippet} @@ -77,7 +130,8 @@ diff --git a/packages/layerchart/src/lib/components/Labels/Labels.canvas.svelte b/packages/layerchart/src/lib/components/Labels/Labels.canvas.svelte index 12755e7f9..1688e7ba8 100644 --- a/packages/layerchart/src/lib/components/Labels/Labels.canvas.svelte +++ b/packages/layerchart/src/lib/components/Labels/Labels.canvas.svelte @@ -7,10 +7,11 @@ import Text from '../Text/Text.canvas.svelte'; import Group from '../Group/Group.canvas.svelte'; import Points from '../Points/Points.canvas.svelte'; + import Link from '../Link/Link.canvas.svelte'; import type { LabelsProps } from './Labels.shared.svelte.js'; let props: LabelsProps = $props(); - + diff --git a/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts b/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts index 9aa81434e..f6a6a6cc7 100644 --- a/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts @@ -1,14 +1,20 @@ import type { ComponentProps, Snippet } from 'svelte'; +import { Delaunay } from 'd3-delaunay'; +import { polygonArea, polygonCentroid } from 'd3-polygon'; import { format as formatValue, type FormatType, type FormatConfig } from '@layerstack/utils'; import type { Without } from '$lib/utils/types.js'; import { accessor, type Accessor } from '$lib/utils/common.js'; import { isScaleBand } from '$lib/utils/scales.svelte.js'; +import { occlude } from '$lib/utils/occlusion.js'; +import { getTextRect } from '$lib/utils/string.js'; import { getChartContext } from '$lib/contexts/chart.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; import { createDimensionGetter } from '$lib/utils/rect.svelte.js'; -import type { TextProps } from '../Text/Text.shared.svelte.js'; +import { getPixelValue, type TextProps } from '../Text/Text.shared.svelte.js'; +import { getPointLabelLayout, getPointLabelRect } from '$lib/utils/labelPlacement.js'; import type { Point } from '../Points/Points.shared.svelte.js'; +import type Link from '../Link/Link.svelte'; export type LabelsPropsWithoutHTML = { /** Override data instead of using context */ @@ -25,13 +31,33 @@ export type LabelsPropsWithoutHTML = { seriesKey?: string; /** @default 'outside' */ placement?: 'inside' | 'outside' | 'middle' | 'center' | 'smart'; + /** + * Global positioning algorithm applied across all labels (distinct from the + * per-point `placement`). `'voronoi'` orients each label towards the open space + * of its cell — good for scatter plots and maps. + */ + layout?: 'voronoi'; + /** + * Hide labels that would overlap a higher-priority (roomier-cell) one, resolving + * the actual boxes rather than dropping by a proxy. Requires `layout`. Pass an + * object to tune the spacing — e.g. `{ padding: 8 }` for a sparser result. + */ + occlude?: boolean | { padding?: number }; + /** + * With `layout="voronoi"`, move each label out into its cell's open space and draw + * a leader line back to the point (d3-ring-note / smart-labels style). Pass `true` + * for a straight line, or an object to configure the ``. + */ + links?: boolean | Partial>; /** @default placement === 'center' || placement === 'middle' ? 0 : 4 */ offset?: number; /** The format of the label */ format?: FormatType | FormatConfig; /** @default (d, index) => index */ key?: (d: T, index: number) => any; - children?: Snippet<[{ data: Point; textProps: TextProps }]>; + children?: Snippet< + [{ data: Point; textProps: TextProps; link?: { x1: number; y1: number; x2: number; y2: number } | null }] + >; }; export type LabelsProps = LabelsPropsWithoutHTML & @@ -222,4 +248,127 @@ export class LabelsState { return result; } + + /** + * `layout="voronoi"`: orient each label towards the open space of its Voronoi cell. + * With `links`, move the label out to the cell centroid and draw a leader back to the + * point (using AnnotationPoint's `smart` geometry). When `occlude` is set, drop labels + * that would overlap a roomier-cell label. Returns per-point `{ textProps, link, visible }`, + * index-aligned to `points`. + */ + getVoronoiLabels(points: Point[]): Array<{ + textProps: TextProps; + link: { x1: number; y1: number; x2: number; y2: number } | null; + visible: boolean; + }> { + const props = this.#getProps(); + const offset = props.offset ?? 4; + const fontSize = getPixelValue(props.fontSize ?? 12); + const links = props.links != null && props.links !== false; + // Don't fling labels across the chart when a cell's centroid is distant (sparse regions) + const maxMove = this.ctx.width * 0.2; + + // Four candidate orientations (towards the cell centroid / open space) + const orient = [ + { textAnchor: 'start', dx: offset, dy: 0 }, + { textAnchor: 'middle', dx: 0, dy: offset + fontSize / 2 }, + { textAnchor: 'end', dx: -offset, dy: 0 }, + { textAnchor: 'middle', dx: 0, dy: -(offset + fontSize / 2) }, + ] as const; + + const voronoi = Delaunay.from( + points, + (p) => p.x, + (p) => p.y + ).voronoi([0, 0, this.ctx.width, this.ctx.height]); + + type Leader = { x1: number; y1: number; x2: number; y2: number } | null; + + const candidates = points.map((point, i) => { + const polygon = voronoi.cellPolygon(i) as [number, number][] | null; + const centroid = polygon ? polygonCentroid(polygon) : [point.x, point.y]; + const area = polygon ? Math.abs(polygonArea(polygon)) : 0; + + const displayValue = props.value + ? accessor(props.value)(point.data) + : isScaleBand(this.ctx.yScale) + ? point.xValue + : point.yValue; + const text = String(formatValue(displayValue, props.format as FormatType)); + const fill = typeof props.fill === 'function' ? accessor(props.fill)(point.data) : props.fill; + + if (links) { + // Move the label into the cell's open space (unless the centroid is too far) + const dist = Math.hypot(centroid[0] - point.x, centroid[1] - point.y); + const move = polygon != null && dist > 1e-6 && dist <= maxMove; + const opts = { + x: point.x, + y: point.y, + labelPlacement: 'smart' as const, + labelX: move ? centroid[0] : point.x, + labelY: move ? centroid[1] : point.y, + fontSize, + link: move, + }; + const layout = getPointLabelLayout(opts); + return { + point, + i, + area, + textProps: { + value: text, + fill, + x: layout.text.x, + y: layout.text.y, + textAnchor: layout.text.textAnchor, + verticalAnchor: layout.text.verticalAnchor, + } as TextProps, + box: getPointLabelRect(text, opts), + link: (move + ? { x1: point.x, y1: point.y, x2: layout.anchor.x, y2: layout.anchor.y } + : null) as Leader, + }; + } + + // Orient the label near the point, towards the open space (no leader) + const angle = + (Math.round((Math.atan2(centroid[1] - point.y, centroid[0] - point.x) / Math.PI) * 2) + 4) % + 4; + const o = orient[angle]; + return { + point, + i, + area, + textProps: { + value: text, + fill, + x: point.x, + y: point.y, + dx: o.dx, + dy: o.dy, + textAnchor: o.textAnchor, + verticalAnchor: 'middle', + } as TextProps, + box: getTextRect(text, point.x, point.y, { + dx: o.dx, + dy: o.dy, + textAnchor: o.textAnchor, + fontSize, + }), + link: null as Leader, + }; + }); + + const occludeOn = props.occlude != null && props.occlude !== false; + const padding = typeof props.occlude === 'object' ? (props.occlude.padding ?? 2) : 2; + const visible = occludeOn + ? new Set(occlude(candidates, (c) => c.box, { priority: (c) => c.area, padding }).map((c) => c.i)) + : null; + + return candidates.map((c) => ({ + textProps: c.textProps, + link: c.link, + visible: visible == null || visible.has(c.i), + })); + } } diff --git a/packages/layerchart/src/lib/components/Labels/Labels.svg.svelte b/packages/layerchart/src/lib/components/Labels/Labels.svg.svelte index 854d4a545..2446d1482 100644 --- a/packages/layerchart/src/lib/components/Labels/Labels.svg.svelte +++ b/packages/layerchart/src/lib/components/Labels/Labels.svg.svelte @@ -7,10 +7,11 @@ import Text from '../Text/Text.svg.svelte'; import Group from '../Group/Group.svg.svelte'; import Points from '../Points/Points.svg.svelte'; + import Link from '../Link/Link.svg.svelte'; import type { LabelsProps } from './Labels.shared.svelte.js'; let props: LabelsProps = $props(); - + diff --git a/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts b/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts index 4c9ef5b10..9e1e5c261 100644 --- a/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts @@ -5,7 +5,9 @@ import type { CommonStyleProps, Without } from '$lib/utils/types.js'; import { isScaleBand, type AnyScale } from '$lib/utils/scales.svelte.js'; import { accessor, type Accessor } from '$lib/utils/common.js'; import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; import type { CircleProps } from '../Circle/Circle.shared.svelte.js'; export type Point = { @@ -49,6 +51,7 @@ export type PointsProps = PointsPropsWithoutHTML & export class PointsState { #getProps: () => PointsProps = () => ({}) as PointsProps; ctx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); constructor(getProps: () => PointsProps) { this.#getProps = getProps; @@ -123,6 +126,21 @@ export class PointsState { #getPointObject(xVal: number, yVal: number, d: any, edgeIndex?: number): Point { const props = this.#getProps(); + + // In a geo chart, project the [x, y] pair directly (no band offsets / radial) + if (this.geo.projection) { + const [projX, projY] = this.geo.projection([xVal, yVal]) ?? [0, 0]; + return { + x: projX, + y: projY, + r: this.ctx.config.r ? this.ctx.rGet(d) : (props.r ?? 5), + xValue: xVal, + yValue: yVal, + data: d, + edgeIndex, + }; + } + const scaledX: number = this.ctx.xScale(xVal); const scaledY: number = this.ctx.yScale(yVal); diff --git a/packages/layerchart/src/lib/utils/index.ts b/packages/layerchart/src/lib/utils/index.ts index 91b5ff30b..24a8180fe 100644 --- a/packages/layerchart/src/lib/utils/index.ts +++ b/packages/layerchart/src/lib/utils/index.ts @@ -5,11 +5,14 @@ export * from './common.js'; export * from './dataProp.js'; export * from './geo.js'; export * from './hierarchy.js'; +export * from './labelPlacement.js'; export * from './math.js'; +export * from './occlusion.js'; export * from './path.js'; export * from './pivot.js'; export * from './scales.svelte.js'; export * from './stack.js'; +export * from './string.js'; export * from './ticks.js'; export * from './treemap.js'; export * from './threshold.js'; diff --git a/packages/layerchart/src/lib/utils/labelPlacement.ts b/packages/layerchart/src/lib/utils/labelPlacement.ts new file mode 100644 index 000000000..1ea004735 --- /dev/null +++ b/packages/layerchart/src/lib/utils/labelPlacement.ts @@ -0,0 +1,135 @@ +import { getTextRect } from './string.js'; +import type { Placement } from '../components/types.js'; + +export type PointLabelLayoutOptions = { + /** The point the label attaches to, in pixels (already projected/scaled). */ + x: number; + y: number; + /** Marker radius the label offsets from. @default 4 */ + r?: number; + /** How to place the label relative to the point. @default 'center' */ + labelPlacement?: Placement | 'smart'; + /** + * Explicit pixel position for the label, overriding the `labelPlacement` offset. + * Pair with `labelPlacement="smart"` to auto-orient towards it. + */ + labelX?: number; + labelY?: number; + labelXOffset?: number; + labelYOffset?: number; + /** Font size (pixels) — feeds the vertical cap-height bias. @default 16 */ + fontSize?: number; + /** Spacing between a leader line and the label; only applies with `link`. @default 2 */ + labelGap?: number; + /** Whether a leader line is drawn (nudges the text by `labelGap`). @default false */ + link?: boolean; + /** Caller-provided vertical anchor — when set, the cap-height bias is skipped. */ + verticalAnchor?: 'start' | 'middle' | 'end' | 'inherit'; +}; + +export type PointLabelLayout = { + /** Snapped direction from the point towards the label (−1 | 0 | 1 per axis). */ + direction: { x: number; y: number }; + /** Where a leader line targets (before the `labelGap` nudge). */ + anchor: { x: number; y: number }; + /** Resolved `` placement — pass straight to `` or `getTextRect`. */ + text: { + x: number; + y: number; + textAnchor: 'start' | 'middle' | 'end'; + verticalAnchor: 'start' | 'middle' | 'end'; + }; +}; + +/** + * Resolve where a `smart` (or discrete) placement puts a label attached to a point — + * text position, anchors, and leader direction. Shared by `` and + * ``, so labels can be measured/occluded with the exact + * geometry the component renders instead of re-deriving it. + */ +export function getPointLabelLayout(options: PointLabelLayoutOptions): PointLabelLayout { + const { + x: px, + y: py, + r = 4, + labelPlacement = 'center', + labelX, + labelY, + labelXOffset = 0, + labelYOffset = 0, + fontSize = 16, + labelGap = 2, + link = false, + verticalAnchor, + } = options; + + const explicit = labelX != null || labelY != null; + const capHeight = fontSize * 0.71; + + // Direction from the point towards the label. `smart` derives it from the + // geometry (snapped to the 8 cardinal/diagonal directions); otherwise it + // comes from the discrete placement. + let dirX = 0; + let dirY = 0; + if (labelPlacement === 'smart') { + const ddx = (labelX ?? px) - px; + const ddy = (labelY ?? py) - py; + const ax = Math.abs(ddx); + const ay = Math.abs(ddy); + if (ax > 1e-6 || ay > 1e-6) { + dirX = ax >= ay * 0.4 ? Math.sign(ddx) : 0; + dirY = ay >= ax * 0.4 ? Math.sign(ddy) : 0; + } + } else if (labelPlacement !== 'center') { + dirX = labelPlacement.includes('left') ? -1 : labelPlacement.includes('right') ? 1 : 0; + dirY = labelPlacement.includes('top') ? -1 : labelPlacement.includes('bottom') ? 1 : 0; + } + + const mag = Math.hypot(dirX, dirY) || 1; + const signX = dirX < 0 ? -1 : 1; + const signY = dirY < 0 ? -1 : 1; + + // The leader connects the ring to this anchor — either an explicit + // `labelX`/`labelY`, or offset from the point in the direction. + const anchorX = explicit ? (labelX ?? px) : px + (r * dirX) / mag + labelXOffset * signX; + const anchorY = explicit ? (labelY ?? py) : py + (r * dirY) / mag + labelYOffset * signY; + + // When there's a leader line, nudge the text away from the point (along the + // line) by `labelGap` to leave spacing — the line itself is unchanged. + const gap = link ? labelGap : 0; + const adx = anchorX - px; + const ady = anchorY - py; + const adist = Math.hypot(adx, ady) || 1; + const gapX = (gap * adx) / adist; + const gapY = (gap * ady) / adist; + + // Bias by half the cap height so the near edge (not the center) sits at the + // (gap-adjusted) anchor — keeps top/bottom symmetric for any fontSize. Skip it + // when the caller sets an explicit `verticalAnchor` (they control it). + const capBias = + verticalAnchor != null ? 0 : dirY > 0 ? capHeight / 2 : dirY < 0 ? -capHeight / 2 : 0; + + return { + direction: { x: dirX, y: dirY }, + anchor: { x: anchorX, y: anchorY }, + text: { + x: anchorX + gapX, + y: anchorY + gapY + capBias, + textAnchor: dirX > 0 ? 'start' : dirX < 0 ? 'end' : 'middle', + verticalAnchor: 'middle', + }, + }; +} + +/** + * Bounding box of a point label, combining {@link getPointLabelLayout} with `getTextRect` — + * a reliable `bounds` for `occlude()` when hiding overlapping labels. + */ +export function getPointLabelRect(label: string, options: PointLabelLayoutOptions) { + const { text } = getPointLabelLayout(options); + return getTextRect(label, text.x, text.y, { + textAnchor: text.textAnchor, + verticalAnchor: text.verticalAnchor, + fontSize: options.fontSize ?? 16, + }); +} diff --git a/packages/layerchart/src/lib/utils/occlusion.ts b/packages/layerchart/src/lib/utils/occlusion.ts new file mode 100644 index 000000000..7b4fad049 --- /dev/null +++ b/packages/layerchart/src/lib/utils/occlusion.ts @@ -0,0 +1,58 @@ +import { sortFunc } from '@layerstack/utils'; + +/** Axis-aligned bounding box in pixel space. */ +export type OcclusionRect = { x: number; y: number; width: number; height: number }; + +export type OcclusionOptions = { + /** + * Priority accessor — higher-priority items are placed first and win ties + * against overlapping lower-priority items. Defaults to input order. + */ + priority?: (item: T) => number; + /** Minimum gap (in pixels) required between kept boxes. */ + padding?: number; +}; + +/** + * Greedy label occlusion (à la https://observablehq.com/@d3/occlusion): sort by + * priority, then keep each item only if its box doesn't overlap an already-kept + * one — dropping the rest. Returns the kept items (in priority order). + * + * Brute-force `O(n·k)` overlap testing, where `k` is the (bounded) number kept — + * effectively linear for realistic label counts, so no spatial index is needed. + */ +export function occlude( + items: T[], + bounds: (item: T) => OcclusionRect, + options: OcclusionOptions = {} +): T[] { + const { priority, padding = 0 } = options; + + // Highest priority first; equal priorities keep input order (stable sort). + const ordered = priority ? [...items].sort(sortFunc(priority, 'desc')) : items; + + const kept: T[] = []; + const keptRects: OcclusionRect[] = []; + + for (const item of ordered) { + const r = bounds(item); + let occluded = false; + for (const k of keptRects) { + if ( + r.x - padding < k.x + k.width && + r.x + r.width + padding > k.x && + r.y - padding < k.y + k.height && + r.y + r.height + padding > k.y + ) { + occluded = true; + break; + } + } + if (!occluded) { + kept.push(item); + keptRects.push(r); + } + } + + return kept; +} diff --git a/packages/layerchart/src/lib/utils/string.ts b/packages/layerchart/src/lib/utils/string.ts index d93639ea2..ca5e74757 100644 --- a/packages/layerchart/src/lib/utils/string.ts +++ b/packages/layerchart/src/lib/utils/string.ts @@ -33,6 +33,41 @@ export const getStringWidth = memoize(_getStringWidth, { cacheKey: ([str, style]) => `${str}_${JSON.stringify(style)}`, }); +export type TextRectOptions = { + /** Horizontal placement of `x` within the text. @default 'start' */ + textAnchor?: 'start' | 'middle' | 'end'; + /** Vertical placement of `y` within the text. @default 'middle' */ + verticalAnchor?: 'start' | 'middle' | 'end'; + /** Font size (px) — measures the width and sets the height. @default 16 */ + fontSize?: number; + /** Additional offset applied to `x` / `y` (matching ``'s `dx` / `dy`). */ + dx?: number; + dy?: number; +}; + +/** + * Bounding box (`{ x, y, width, height }`) of `text` anchored at (`x`, `y`) — matching + * how `` positions it for the given `textAnchor`/`verticalAnchor`. Width is measured + * with the same memoized metrics as `` (falling back to a character-count estimate + * when the DOM is unavailable, e.g. during SSR), making it a convenient `bounds` for + * `occlude()`. + */ +export function getTextRect(text: string, x: number, y: number, options: TextRectOptions = {}) { + const { textAnchor = 'start', verticalAnchor = 'middle', fontSize = 16, dx = 0, dy = 0 } = options; + const width = + getStringWidth(text, { fontSize: `${fontSize}px` } as CSSStyleDeclaration) ?? + text.length * fontSize * 0.6; + const height = fontSize; + const ax = x + dx; + const ay = y + dy; + return { + x: textAnchor === 'end' ? ax - width : textAnchor === 'middle' ? ax - width / 2 : ax, + y: verticalAnchor === 'end' ? ay - height : verticalAnchor === 'middle' ? ay - height / 2 : ay, + width, + height, + }; +} + export type RasterizeTextOptions = { fontSize?: string; fontWeight?: number; From 98659a8da9d06629eb638ad1e70218677b3dc00a Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 5 Aug 2026 22:50:53 -0400 Subject: [PATCH 4/7] feat(Chart/geo): Support `clipExtent: true` to clip the projection to the chart dimensions (`[[0, 0], [width, height]]`) --- .changeset/geo-clipextent-viewport.md | 5 +++++ docs/src/content/guides/geo.md | 8 +++++++- .../examples/components/Labels/voronoi-geo.svelte | 8 +++++++- packages/layerchart/src/lib/states/geo.svelte.ts | 15 ++++++++++++--- 4 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 .changeset/geo-clipextent-viewport.md diff --git a/.changeset/geo-clipextent-viewport.md b/.changeset/geo-clipextent-viewport.md new file mode 100644 index 000000000..724e447ad --- /dev/null +++ b/.changeset/geo-clipextent-viewport.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Chart/geo): Support `clipExtent: true` to clip the projection to the chart dimensions (`[[0, 0], [width, height]]`), e.g. to trim the `Sphere` overflow under `geoMercator`. diff --git a/docs/src/content/guides/geo.md b/docs/src/content/guides/geo.md index 57dccc991..68e6489de 100644 --- a/docs/src/content/guides/geo.md +++ b/docs/src/content/guides/geo.md @@ -210,6 +210,12 @@ Restricts rendering to a rectangular pixel region: ``` +Pass `true` to clip to the chart dimensions (`[[0, 0], [width, height]]`) — e.g. to trim the `Sphere` overflow under `geoMercator`. Clips at the projection level, so it also applies to the canvas renderer and leaves non-projected marks (e.g. `Labels`) untouched: + +```svelte + +``` + ## Tooltips on maps Set `tooltip` on each `GeoPath` to wire up pointer events automatically. The default tooltipContext `manual` mode is used, where each shape calls `show`/`hide` on pointer enter/leave: @@ -366,7 +372,7 @@ The `geo` prop on `Chart` provides the primary projection context. Use the `GeoP | `fitGeojson` | `GeoPermissibleObjects` | GeoJSON to fit the projection to | | `fixedAspectRatio` | `number` | Fixed aspect ratio instead of responsive chart dimensions | | `clipAngle` | `number` | Angular extent of visible hemisphere (degrees) | -| `clipExtent` | `[[number, number], [number, number]]` | Rectangular pixel clipping region | +| `clipExtent` | `[[number, number], [number, number]] \| boolean` | Rectangular pixel clipping region (`true` = chart dimensions) | | `rotate` | `{ yaw, pitch, roll }` | Initial rotation in degrees | | `scale` | `number` | Manual projection scale (overrides fitGeojson scale) | | `translate` | `[number, number]` | Manual projection translate (overrides fitGeojson translate) | diff --git a/docs/src/examples/components/Labels/voronoi-geo.svelte b/docs/src/examples/components/Labels/voronoi-geo.svelte index 73a79e612..e89a51a82 100644 --- a/docs/src/examples/components/Labels/voronoi-geo.svelte +++ b/docs/src/examples/components/Labels/voronoi-geo.svelte @@ -84,7 +84,13 @@ - + {#each countries.features as country} diff --git a/packages/layerchart/src/lib/states/geo.svelte.ts b/packages/layerchart/src/lib/states/geo.svelte.ts index a1ebbffd5..7e634d4e6 100644 --- a/packages/layerchart/src/lib/states/geo.svelte.ts +++ b/packages/layerchart/src/lib/states/geo.svelte.ts @@ -14,7 +14,12 @@ export type GeoStateProps = { */ fixedAspectRatio?: number; clipAngle?: number; - clipExtent?: [[number, number], [number, number]]; + /** + * Clip rendered geometry to a pixel rectangle. Pass `[[x0, y0], [x1, y1]]`, or `true` + * to clip to the chart dimensions (`[[0, 0], [width, height]]`) — e.g. to trim the + * `Sphere` overflow under `geoMercator`. + */ + clipExtent?: [[number, number], [number, number]] | boolean; rotate?: { /** Lambda (Center Meridian) */ yaw: number; @@ -122,9 +127,13 @@ export class GeoState { _projection.clipAngle(this.props.clipAngle); } - // Apply clipExtent + // Apply clipExtent (`true` -> clip to chart dimensions, matching `fitSize` range) if (this.props.clipExtent && 'clipExtent' in _projection) { - _projection.clipExtent(this.props.clipExtent); + const clipExtent = + this.props.clipExtent === true + ? ([[0, 0], this.fitSizeRange] as [[number, number], [number, number]]) + : this.props.clipExtent; + _projection.clipExtent(clipExtent); } return _projection; From 9a4bd0f7fa1308b069170adf9fdb0c01f406e6ac Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 5 Aug 2026 22:55:43 -0400 Subject: [PATCH 5/7] update changeset --- .changeset/geo-clipextent-viewport.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/geo-clipextent-viewport.md b/.changeset/geo-clipextent-viewport.md index 724e447ad..50aebe2b9 100644 --- a/.changeset/geo-clipextent-viewport.md +++ b/.changeset/geo-clipextent-viewport.md @@ -2,4 +2,4 @@ 'layerchart': minor --- -feat(Chart/geo): Support `clipExtent: true` to clip the projection to the chart dimensions (`[[0, 0], [width, height]]`), e.g. to trim the `Sphere` overflow under `geoMercator`. +feat(GeoState): Support `clipExtent: true` to clip the projection to the chart dimensions (`[[0, 0], [width, height]]`), e.g. to trim the `Sphere` overflow under `geoMercator`. From a251eeb4ecd7a4080b5841bbbeb29cd4df5e057e Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 5 Aug 2026 23:01:48 -0400 Subject: [PATCH 6/7] improve changeset and docs --- .changeset/labels-voronoi-layout.md | 2 +- docs/src/content/components/Labels.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/labels-voronoi-layout.md b/.changeset/labels-voronoi-layout.md index 75ddf6696..2136ce97d 100644 --- a/.changeset/labels-voronoi-layout.md +++ b/.changeset/labels-voronoi-layout.md @@ -2,4 +2,4 @@ 'layerchart': minor --- -feat(Labels): Add `layout="voronoi"` to place each label in its Voronoi cell's open space, with `occlude` to drop overlaps and `links` to move labels out with a leader line back to the point (scatter/map "smart labels"), and make the `fontSize` prop take effect. +feat(Labels): Add `layout="voronoi"` to place each label towards Voronoi cell's centroid, `occlude` to drop overlapping labels and `links` to move labels to centroid with a leader line back to the point. diff --git a/docs/src/content/components/Labels.md b/docs/src/content/components/Labels.md index 2696556b1..d3c25fe72 100644 --- a/docs/src/content/components/Labels.md +++ b/docs/src/content/components/Labels.md @@ -13,7 +13,7 @@ related: [] By default labels will be on the outside of bars, above for positive values and below for negative values -:example{ component="Bars" name="vertical-outside-labels-default" showCode } +:example{ component="Bars" name="vertical-outside-labels-default" } You can also use `placement="inside"` to place within the bars (near the value edge) @@ -58,12 +58,12 @@ cell, `occlude` to hide labels that would overlap a roomier-cell neighbor, and `links` to move each label out into open space with a leader line back to the point (d3-ring-note / smart-labels style) — no per-point placement setup required. -:example{ name="voronoi" showCode } +:example{ name="voronoi" } It works on maps too — in a geo ``, `` and the labels project through the projection, so the same props place airport labels in open space with leaders. -:example{ name="voronoi-geo" showCode } +:example{ name="voronoi-geo" } ### Simplified charts From 09c15fa350b92e76201d1e77ceb5bffef117a056 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 5 Aug 2026 23:30:32 -0400 Subject: [PATCH 7/7] cleanup changesets and docs --- .changeset/occlude-util.md | 5 ----- .changeset/point-label-placement.md | 5 ----- .changeset/points-geo.md | 2 +- docs/src/content/components/Voronoi.md | 2 +- 4 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 .changeset/occlude-util.md delete mode 100644 .changeset/point-label-placement.md diff --git a/.changeset/occlude-util.md b/.changeset/occlude-util.md deleted file mode 100644 index 2e715d9d4..000000000 --- a/.changeset/occlude-util.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'layerchart': minor ---- - -feat(utils): Add `occlude()` for greedy non-overlapping label placement, plus `getTextRect()` (and now-exported `getStringWidth()`) to measure a text label's box the same way `` does — a convenient `bounds` for `occlude()`. diff --git a/.changeset/point-label-placement.md b/.changeset/point-label-placement.md deleted file mode 100644 index 663584f4c..000000000 --- a/.changeset/point-label-placement.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'layerchart': minor ---- - -feat(utils): Add `getPointLabelLayout()` / `getPointLabelRect()` to resolve a point label's placement and bounding box (`smart` or discrete) — the shared geometry behind `` and ``, and a convenient `bounds` for `occlude()`. diff --git a/.changeset/points-geo.md b/.changeset/points-geo.md index a3bb81692..d0e2f7f9b 100644 --- a/.changeset/points-geo.md +++ b/.changeset/points-geo.md @@ -2,4 +2,4 @@ 'layerchart': minor --- -feat(Points | Labels): Project through the chart's geo projection when present, so `` (and ``) render on maps. +feat(Points|Labels): Project through the chart's geo projection when present, so `` (and ``) render on maps. diff --git a/docs/src/content/components/Voronoi.md b/docs/src/content/components/Voronoi.md index 00a4727b0..7aa1b38d5 100644 --- a/docs/src/content/components/Voronoi.md +++ b/docs/src/content/components/Voronoi.md @@ -2,7 +2,7 @@ description: Interaction component which creates Voronoi diagrams to divide a plane according to the nearest points, aiding spatial analysis and visualization. category: interactions layers: [svg, canvas] -related: [TooltipContext, AnnotationPoint] +related: [TooltipContext, Labels] --- ## Usage