Skip to content

Commit e974a50

Browse files
committed
fix: Skip mark x/y/data from domain/series calculation when geo projection is active
1 parent ad022e7 commit e974a50

5 files changed

+146
-1
lines changed

.changeset/fix-geo-mark-domain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'layerchart': patch
3+
---
4+
5+
fix: Skip mark x/y/data from domain/series calculation when geo projection is active

packages/layerchart/src/lib/states/chart.svelte.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
22
import { flushSync } from 'svelte';
33

44
import { scaleBand } from 'd3-scale';
5+
import { geoAlbersUsa } from 'd3-geo';
56
import { timeDay } from 'd3-time';
67

78
import { ChartState } from './chart.svelte.js';
@@ -11,6 +12,7 @@ import { isScaleBand, isScaleTime } from '$lib/utils/scales.svelte.js';
1112
type TestData = { date: string; value: number };
1213
type MultiSeriesData = { date: string; apples: number; bananas: number };
1314
type WideData = { year: string; apples: number; bananas: number; cherries: number; grapes: number };
15+
type GeoData = { name: string; longitude: number; latitude: number };
1416

1517
function createChartState<T = TestData>(props: Partial<ChartPropsWithoutHTML<T>>) {
1618
let cleanup: () => void;
@@ -603,6 +605,130 @@ describe('ChartState mark registration', () => {
603605
});
604606
});
605607

608+
describe('ChartState geo projection skips markInfo', () => {
609+
const geoData: GeoData[] = [
610+
{ name: 'New York', longitude: -74.006, latitude: 40.7128 },
611+
{ name: 'Los Angeles', longitude: -118.2437, latitude: 34.0522 },
612+
{ name: 'Chicago', longitude: -87.6298, latitude: 41.8781 },
613+
];
614+
615+
it('should not create implicit series from marks when geo projection is active', () => {
616+
const { state, cleanup } = createChartState<GeoData>({
617+
data: geoData,
618+
x: 'longitude',
619+
y: 'latitude',
620+
geo: { projection: geoAlbersUsa },
621+
});
622+
623+
try {
624+
// Register a mark with its own data (like a tooltip highlight Circle)
625+
state.registerMark({ data: [geoData[0]], x: 'longitude', y: 'latitude' });
626+
flushSync();
627+
628+
// Should remain default series — mark should not create implicit "latitude" series
629+
expect(state.seriesState.isDefaultSeries).toBe(true);
630+
} finally {
631+
cleanup();
632+
}
633+
});
634+
635+
it('should not add mark data to flatData when geo projection is active', () => {
636+
const { state, cleanup } = createChartState<GeoData>({
637+
data: geoData,
638+
x: 'longitude',
639+
y: 'latitude',
640+
geo: { projection: geoAlbersUsa },
641+
});
642+
643+
try {
644+
state.registerMark({ data: [geoData[0]], x: 'longitude', y: 'latitude' });
645+
flushSync();
646+
647+
// flatData should only contain chart data, not the mark's extra data
648+
expect(state.flatData).toHaveLength(3);
649+
expect(state.flatData).toBe(geoData);
650+
} finally {
651+
cleanup();
652+
}
653+
});
654+
655+
it('should not derive x/y accessors from marks when geo projection is active', () => {
656+
// Chart with geo but no explicit x/y — marks should not fill in the accessors
657+
const { state: stateWithGeo, cleanup: cleanupGeo } = createChartState<GeoData>({
658+
data: geoData,
659+
geo: { projection: geoAlbersUsa },
660+
});
661+
662+
const { state: stateWithoutGeo, cleanup: cleanupNoGeo } = createChartState<GeoData>({
663+
data: geoData,
664+
});
665+
666+
try {
667+
// Both start with null x accessor (no x prop set)
668+
expect(stateWithGeo.x).toBeNull();
669+
expect(stateWithoutGeo.x).toBeNull();
670+
671+
stateWithGeo.registerMark({ x: 'longitude', y: 'latitude' });
672+
stateWithoutGeo.registerMark({ x: 'longitude', y: 'latitude' });
673+
flushSync();
674+
675+
// Without geo: mark should derive x accessor
676+
expect(stateWithoutGeo.x).not.toBeNull();
677+
expect(stateWithoutGeo.x!(geoData[0])).toBe(geoData[0].longitude);
678+
679+
// With geo: mark should NOT derive x accessor
680+
expect(stateWithGeo.x).toBeNull();
681+
} finally {
682+
cleanupGeo();
683+
cleanupNoGeo();
684+
}
685+
});
686+
687+
it('should preserve seriesKey/color/label from marks in geo mode for legends', () => {
688+
const { state, cleanup } = createChartState<GeoData>({
689+
data: geoData,
690+
x: 'longitude',
691+
y: 'latitude',
692+
geo: { projection: geoAlbersUsa },
693+
});
694+
695+
try {
696+
state.registerMark({ seriesKey: 'earthquakes', color: 'red', label: 'Earthquakes' });
697+
state.registerMark({ seriesKey: 'volcanos', color: 'orange', label: 'Volcanos' });
698+
flushSync();
699+
700+
// seriesKey/color/label should still create implicit series for legends
701+
expect(state.seriesState.isDefaultSeries).toBe(false);
702+
expect(state.seriesState.series).toHaveLength(2);
703+
expect(state.seriesState.series[0]).toMatchObject({ key: 'earthquakes', color: 'red', label: 'Earthquakes' });
704+
expect(state.seriesState.series[1]).toMatchObject({ key: 'volcanos', color: 'orange', label: 'Volcanos' });
705+
706+
// But flatData should not include extra mark data
707+
expect(state.flatData).toHaveLength(3);
708+
} finally {
709+
cleanup();
710+
}
711+
});
712+
713+
it('should still process marks normally without geo projection', () => {
714+
const { state, cleanup } = createChartState<GeoData>({
715+
data: geoData,
716+
x: 'name',
717+
});
718+
719+
try {
720+
state.registerMark({ y: 'latitude', color: 'blue' });
721+
flushSync();
722+
723+
// Without geo, marks should create implicit series as normal
724+
expect(state.seriesState.isDefaultSeries).toBe(false);
725+
expect(state.seriesState.series[0].key).toBe('latitude');
726+
} finally {
727+
cleanup();
728+
}
729+
});
730+
});
731+
606732
describe('ChartState implicit series domain update on visibility toggle', () => {
607733
it('should update y domain when hiding an implicit series', () => {
608734
const data: MultiSeriesData[] = [

packages/layerchart/src/lib/states/chart.svelte.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,24 @@ export class ChartState<
128128
private _nextMarkId = 0;
129129

130130
/** Reactive accessor — reads _markInfosVersion to create a reactive dependency,
131-
* returns the plain array so items are never wrapped in Svelte proxies. */
131+
* returns the plain array so items are never wrapped in Svelte proxies.
132+
*
133+
* When a geo projection is active, strips x/y/data from mark info — those
134+
* values are geographic coordinates handled by the projection, not xScale/yScale.
135+
* seriesKey/color/label are preserved so marks can still contribute to legends. */
132136
private get _markInfos() {
133137
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
134138
this._markInfosVersion;
139+
if (this.geoState.props.projection) {
140+
return this._markInfosRaw.map(({ _id, info }) => ({
141+
_id,
142+
info: {
143+
seriesKey: info.seriesKey,
144+
color: info.color,
145+
label: info.label,
146+
} as MarkInfo,
147+
}));
148+
}
135149
return this._markInfosRaw;
136150
}
137151

0 commit comments

Comments
 (0)