Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/annotationpoint-smart-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat(AnnotationPoint): Support explicit `labelX`/`labelY` positioning, a `fontSize` prop, a `labelGap` for callout spacing, and a `labelPlacement="smart"` option that auto-orients the label and terminates its callout at the label edge.
5 changes: 5 additions & 0 deletions .changeset/geo-clipextent-viewport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

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`.
5 changes: 5 additions & 0 deletions .changeset/labels-voronoi-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

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.
5 changes: 5 additions & 0 deletions .changeset/points-geo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat(Points|Labels): Project through the chart's geo projection when present, so `<Points>` (and `<Labels>`) render on maps.
5 changes: 5 additions & 0 deletions .changeset/voronoi-cell-children.md
Original file line number Diff line number Diff line change
@@ -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).
16 changes: 15 additions & 1 deletion docs/src/content/components/Labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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" }

It works on maps too — in a geo `<Chart>`, `<Points>` and the labels project through
the projection, so the same props place airport labels in open space with leaders.

:example{ name="voronoi-geo" }

### Simplified charts

Labels are also integrated in simplified charts via the `labels` prop
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/components/Voronoi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
related: [TooltipContext, Labels]
---

## Usage
Expand Down
8 changes: 7 additions & 1 deletion docs/src/content/guides/geo.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ Restricts rendering to a rectangular pixel region:
<Chart geo={{ projection: geoMercator, clipExtent: [[0, 0], [width, height]] }}>
```

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
<Chart geo={{ projection: geoMercator, fitGeojson: countries, clipExtent: true }}>
```

## 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:
Expand Down Expand Up @@ -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) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
labelPlacement: 'top',
labelXOffset: 20,
labelYOffset: 40,
labelGap: 0,
link: { type: 'beveled', radius: 15, sweep: 'vertical-horizontal' },
props: {
circle: { class: 'stroke-secondary fill-secondary/10' },
Expand Down
37 changes: 31 additions & 6 deletions docs/src/examples/components/AnnotationPoint/playground.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
'right',
'bottom-left',
'bottom',
'bottom-right'
'bottom-right',
'smart'
] as const;

const anchorOptions = ['start', 'middle', 'end'].map((v) => ({ label: v, value: v }));
Expand All @@ -40,10 +41,12 @@
let dataX = $state(80);
let dataY = $state(4.25);

let placement: Placement = $state('bottom-right');
let placement: Placement | 'smart' = $state('bottom-right');
let xOffset = $state(50);
let yOffset = $state(50);
let radius = $state(100);
let radius = $state(60);
let fontSize = $state(16);
let labelGap = $state(2);
let textAnchor: 'start' | 'middle' | 'end' = $state('middle');
let verticalAnchor: 'start' | 'middle' | 'end' = $state('start');

Expand Down Expand Up @@ -134,6 +137,20 @@
max={200}
classes={{ root: 'flex-1 basis-40' }}
/>
<RangeField
label="Font size"
bind:value={fontSize}
min={8}
max={48}
classes={{ root: 'flex-1 basis-40' }}
/>
<RangeField
label="Label gap"
bind:value={labelGap}
min={0}
max={20}
classes={{ root: 'flex-1 basis-40' }}
/>
</div>

<div class="flex flex-wrap gap-2 mb-2 screenshot-hidden">
Expand Down Expand Up @@ -196,6 +213,8 @@
tooltipContext={false}
>
{#snippet aboveMarks({ context })}
{@const ringX = context.xScale(dataX)}
{@const ringY = context.yScale(dataY)}
<AnnotationPoint
x={dataX}
y={dataY}
Expand All @@ -204,16 +223,22 @@
labelPlacement={placement}
labelXOffset={xOffset}
labelYOffset={yOffset}
labelX={placement === 'smart' ? ringX + xOffset : undefined}
labelY={placement === 'smart' ? ringY + yOffset : undefined}
{fontSize}
{labelGap}
{link}
props={{
circle: { class: 'stroke-secondary' },
label: { class: 'fill-secondary font-bold', textAnchor, verticalAnchor }
label: {
class: 'fill-secondary font-bold',
// `smart` derives the anchor from geometry; otherwise use the manual controls
...(placement === 'smart' ? {} : { textAnchor, verticalAnchor })
}
}}
/>

{#if showControls}
{@const ringX = context.xScale(dataX)}
{@const ringY = context.yScale(dataY)}
{@const labelX = ringX + (radius * dirX) / dirMag + xOffset * signX}
{@const labelY = ringY + (radius * dirY) / dirMag + yOffset * signY}

Expand Down
110 changes: 110 additions & 0 deletions docs/src/examples/components/Labels/voronoi-geo.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<script module lang="ts">
import { getCountriesTopology, getWorldAirports } from '$lib/geo.remote';

const [topology, allAirports] = await Promise.all([getCountriesTopology(), getWorldAirports()]);
const airports = allAirports.filter((_, i) => i % 20 === 0);
</script>

<script lang="ts">
import {
geoEqualEarth,
geoEquirectangular,
geoMercator,
geoNaturalEarth1,
type GeoProjection
} from 'd3-geo';
import { feature } from 'topojson-client';
import { Field, RangeField, SelectField, Switch } from 'svelte-ux';

import { Chart, Labels, Layer, Points } from 'layerchart';
import { GeoPath } from 'layerchart/geo';

type LinkType = 'straight' | 'square' | 'beveled' | 'rounded' | 'swoop';

const countries = feature(topology, topology.objects.countries);

const projections: Array<{ label: string; value: () => GeoProjection }> = [
{ label: 'Equirectangular', value: geoEquirectangular },
{ label: 'Natural Earth', value: geoNaturalEarth1 },
{ label: 'Equal Earth', value: geoEqualEarth },
{ label: 'Mercator', value: geoMercator }
];

const linkTypeOptions: Array<{ label: string; value: LinkType }> = [
{ label: 'Straight', value: 'straight' },
{ label: 'Swoop', value: 'swoop' },
{ label: 'Rounded', value: 'rounded' },
{ label: 'Square', value: 'square' },
{ label: 'Beveled', value: 'beveled' }
];

let projection = $state(geoNaturalEarth1);
let linkType = $state<LinkType>('straight');
let useLinks = $state(true);
let occludeLabels = $state(true);
let spacing = $state(3);

const data = airports;
export { data };
</script>

<div class="flex flex-wrap items-center gap-4 mb-2 screenshot-hidden">
<SelectField
label="Projection"
options={projections}
bind:value={projection}
clearable={false}
toggleIcon={null}
stepper
class="w-60"
/>
<SelectField
label="Links"
options={linkTypeOptions}
bind:value={linkType}
clearable={false}
toggleIcon={null}
stepper
class="w-60"
>
<div
slot="append"
class="flex items-center pl-2"
onclick={(e) => e.stopPropagation()}
role="none"
>
<Switch bind:checked={useLinks} size="md" />
</div>
</SelectField>
<Field label="Occlude" let:id>
<div class="flex items-center gap-2 w-60">
<RangeField bind:value={spacing} min={0} max={12} disabled={!occludeLabels} class="flex-1" />
<Switch bind:checked={occludeLabels} size="md" {id} />
</div>
</Field>
</div>

<Chart
{data}
x="longitude"
y="latitude"
geo={{ projection, fitGeojson: { type: 'Sphere' } }}
height={500}
>
<Layer>
<GeoPath geojson={{ type: 'Sphere' }} class="fill-surface-100 stroke-surface-content/20" />
{#each countries.features as country}
<GeoPath geojson={country} class="fill-surface-content/10 stroke-surface-100" />
{/each}

<Points r={1.5} class="fill-surface-content" />
<Labels
value={(d) => 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"
/>
</Layer>
</Chart>
80 changes: 80 additions & 0 deletions docs/src/examples/components/Labels/voronoi.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<script lang="ts">
import { range } from 'd3-array';
import { randomNormal } from 'd3-random';
import { Field, RangeField, SelectField, Switch } from 'svelte-ux';

import { Chart, Labels, Layer, Points, Voronoi } from 'layerchart';

type LinkType = 'straight' | 'square' | 'beveled' | 'rounded' | 'swoop';

const width = 900;
const height = 600;
const randomX = randomNormal(width / 2, 110);
const randomY = randomNormal(height / 2, 95);
const data = range(110)
.map((i) => ({ i, x: randomX(), y: randomY() }))
.filter((d) => d.x >= 0 && d.x <= width && d.y >= 0 && d.y <= height);

const linkTypeOptions: Array<{ label: string; value: LinkType }> = [
{ label: 'Straight', value: 'straight' },
{ label: 'Swoop', value: 'swoop' },
{ label: 'Rounded', value: 'rounded' },
{ label: 'Square', value: 'square' },
{ label: 'Beveled', value: 'beveled' }
];

let linkType = $state<LinkType>('straight');
let useLinks = $state(true);
let occludeLabels = $state(true);
let spacing = $state(2);
let showVoronoi = $state(false);

export { data };
</script>

<div class="flex flex-wrap items-center gap-4 mb-2 screenshot-hidden">
<SelectField
label="Links"
options={linkTypeOptions}
bind:value={linkType}
clearable={false}
toggleIcon={null}
stepper
class="w-60"
>
<div
slot="append"
class="flex items-center pl-2"
onclick={(e) => e.stopPropagation()}
role="none"
>
<Switch bind:checked={useLinks} size="md" />
</div>
</SelectField>
<Field label="Occlude" let:id>
<div class="flex items-center gap-2 w-60">
<RangeField bind:value={spacing} min={0} max={50} disabled={!occludeLabels} class="flex-1" />
<Switch bind:checked={occludeLabels} size="md" {id} />
</div>
</Field>
<Field label="Show voronoi" let:id>
<Switch bind:checked={showVoronoi} {id} />
</Field>
</div>

<Chart {data} x="x" xDomain={[0, width]} y="y" yDomain={[0, height]} padding={16} height={500}>
<Layer>
{#if showVoronoi}
<Voronoi classes={{ path: 'stroke-surface-content/20' }} />
{/if}
<Points r={2} class="fill-surface-content" />
<Labels
value={(d) => 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"
/>
</Layer>
</Chart>
2 changes: 2 additions & 0 deletions packages/layerchart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading