@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.3
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/dist/index.d.ts +2 -1
- package/dist/index.js +374 -25
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compile/size-scale.ts +2 -0
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +73 -12
- package/src/geo/__tests__/compile-map.test.ts +211 -0
- package/src/geo/compile-map.ts +402 -15
- package/src/geo/types.ts +2 -0
package/src/geo/compile-map.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
MapFocus,
|
|
11
11
|
MapFocusLayout,
|
|
12
12
|
MapLayout,
|
|
13
|
+
MapPointMark,
|
|
13
14
|
ResolvedAnimation,
|
|
14
15
|
ResolvedTheme,
|
|
15
16
|
TextStyle,
|
|
@@ -33,10 +34,12 @@ import { geoArea, geoPath } from 'd3-geo';
|
|
|
33
34
|
import { scaleQuantile } from 'd3-scale';
|
|
34
35
|
import { feature as topoFeature, mesh as topoMesh } from 'topojson-client';
|
|
35
36
|
import type { GeometryCollection, Topology } from 'topojson-specification';
|
|
37
|
+
import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../compile/size-scale';
|
|
36
38
|
import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar';
|
|
37
39
|
import { resolveAnimation } from '../compiler/animation';
|
|
38
40
|
import { compile as compileSpec } from '../compiler/index';
|
|
39
41
|
import {
|
|
42
|
+
CONTINUOUS_BAR_HEIGHT,
|
|
40
43
|
CONTINUOUS_LABEL_GAP,
|
|
41
44
|
computeContinuousLegendContentForChannel,
|
|
42
45
|
} from '../legend/continuous';
|
|
@@ -162,14 +165,14 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
162
165
|
// 8. Legend height reserve
|
|
163
166
|
const showLegend = mapSpec.legend?.show !== false;
|
|
164
167
|
const colorEncoding = mapSpec.encoding.color;
|
|
165
|
-
const isQuantitative = colorEncoding
|
|
168
|
+
const isQuantitative = colorEncoding?.type === 'quantitative';
|
|
166
169
|
const legendPosition = mapSpec.legend?.position === 'bottom' ? 'bottom' : 'top';
|
|
167
170
|
|
|
168
171
|
// Compute legend block height. For quantitative maps, use the continuous
|
|
169
172
|
// legend infrastructure; for categorical, estimate a single swatch row.
|
|
170
173
|
let legendBlockHeight = 0;
|
|
171
174
|
let continuousContent: ReturnType<typeof computeContinuousLegendContentForChannel> = null;
|
|
172
|
-
if (showLegend && isQuantitative) {
|
|
175
|
+
if (showLegend && colorEncoding && isQuantitative) {
|
|
173
176
|
const colorValues: number[] = [];
|
|
174
177
|
for (const row of mapSpec.data) {
|
|
175
178
|
const v = row[colorEncoding.field];
|
|
@@ -181,8 +184,8 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
181
184
|
// Maps always use a quantile color scale, so tell the legend infra
|
|
182
185
|
// to produce binned swatches rather than a gradient bar.
|
|
183
186
|
const legendChannel: EncodingChannel = {
|
|
184
|
-
...
|
|
185
|
-
scale: { ...
|
|
187
|
+
...colorEncoding,
|
|
188
|
+
scale: { ...colorEncoding.scale, type: 'quantile' },
|
|
186
189
|
};
|
|
187
190
|
continuousContent = computeContinuousLegendContentForChannel(
|
|
188
191
|
colorValues,
|
|
@@ -194,10 +197,22 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
194
197
|
const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
195
198
|
legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
|
|
196
199
|
}
|
|
197
|
-
} else if (showLegend) {
|
|
200
|
+
} else if (showLegend && colorEncoding) {
|
|
198
201
|
const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
199
202
|
legendBlockHeight = Math.max(10, labelHeight) + 6;
|
|
200
203
|
}
|
|
204
|
+
|
|
205
|
+
// Reserve height for point color legend if applicable
|
|
206
|
+
if (showLegend && mapSpec.points?.color) {
|
|
207
|
+
if (mapSpec.points.color.type === 'quantitative') {
|
|
208
|
+
const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
209
|
+
legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
|
|
210
|
+
} else {
|
|
211
|
+
const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
212
|
+
legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8; // swatch row + gap
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
201
216
|
const legendReserveGap = legendBlockHeight > 0 ? 8 : 0;
|
|
202
217
|
|
|
203
218
|
const mapAreaHeight = fullArea.height - legendBlockHeight - legendReserveGap;
|
|
@@ -243,7 +258,12 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
243
258
|
mapSpec.encoding.key.field,
|
|
244
259
|
mapSpec.geo.idField,
|
|
245
260
|
);
|
|
246
|
-
|
|
261
|
+
// In basemap-only mode (empty data + points layer), all features are intentionally unmatched
|
|
262
|
+
const filteredJoinWarnings =
|
|
263
|
+
mapSpec.data.length === 0 && mapSpec.points
|
|
264
|
+
? joinWarnings.filter((w) => w.code !== 'UNMATCHED_FEATURES')
|
|
265
|
+
: joinWarnings;
|
|
266
|
+
compileWarnings.push(...filteredJoinWarnings);
|
|
247
267
|
|
|
248
268
|
// 12. Check for null-projecting features (albersUsa drops territories)
|
|
249
269
|
const droppedFeatures: string[] = [];
|
|
@@ -257,13 +277,22 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
257
277
|
let continuousLegend: ContinuousLegendLayout | null = null;
|
|
258
278
|
let categoricalLegend: CategoricalLegendLayout | null = null;
|
|
259
279
|
|
|
260
|
-
if (
|
|
280
|
+
if (!colorEncoding) {
|
|
281
|
+
// Basemap-only mode: all features get neutral fill, no legend
|
|
282
|
+
featureMarks = buildBasemapMarks({
|
|
283
|
+
geoFeatures,
|
|
284
|
+
pathGen,
|
|
285
|
+
neutralFill,
|
|
286
|
+
neutralStroke,
|
|
287
|
+
droppedFeatures,
|
|
288
|
+
});
|
|
289
|
+
} else if (isQuantitative) {
|
|
261
290
|
const result = buildQuantitativeMarks({
|
|
262
291
|
geoFeatures,
|
|
263
292
|
pathGen,
|
|
264
293
|
joined,
|
|
265
294
|
colorField: colorEncoding.field,
|
|
266
|
-
palette:
|
|
295
|
+
palette: colorEncoding.scale?.scheme ?? 'blue',
|
|
267
296
|
isDarkMode: !!isDarkMode,
|
|
268
297
|
neutralFill,
|
|
269
298
|
neutralStroke,
|
|
@@ -364,6 +393,234 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
364
393
|
const outlineStroke = isDarkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.3)';
|
|
365
394
|
const borders: MapBorders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
|
|
366
395
|
|
|
396
|
+
// 15b. Point marks (symbol layer above choropleth)
|
|
397
|
+
const pointMarks: MapPointMark[] = [];
|
|
398
|
+
let pointCategoricalLegend: CategoricalLegendLayout | null = null;
|
|
399
|
+
let pointContinuousLegend: ContinuousLegendLayout | null = null;
|
|
400
|
+
|
|
401
|
+
if (mapSpec.points) {
|
|
402
|
+
const pts = mapSpec.points;
|
|
403
|
+
const lonField = pts.longitude.field;
|
|
404
|
+
const latField = pts.latitude.field;
|
|
405
|
+
const keyChannel = pts.key;
|
|
406
|
+
|
|
407
|
+
// Size scale
|
|
408
|
+
const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
|
|
409
|
+
|
|
410
|
+
// Color scale (independent from choropleth)
|
|
411
|
+
let pointColorScale: ((val: unknown) => string) | null = null;
|
|
412
|
+
const pointCategories: string[] = [];
|
|
413
|
+
const pointCategoryColors = new Map<string, string>();
|
|
414
|
+
|
|
415
|
+
if (pts.color) {
|
|
416
|
+
if (pts.color.type === 'quantitative') {
|
|
417
|
+
const colorValues: number[] = [];
|
|
418
|
+
for (const row of pts.data) {
|
|
419
|
+
const raw = row[pts.color.field];
|
|
420
|
+
if (raw != null) {
|
|
421
|
+
const v = Number(raw);
|
|
422
|
+
if (Number.isFinite(v)) colorValues.push(v);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (colorValues.length > 0) {
|
|
426
|
+
const palette = pts.color.scale?.scheme ?? 'blue';
|
|
427
|
+
const paletteStops = [...(SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue)];
|
|
428
|
+
const qScale = scaleQuantile<string>().domain(colorValues).range(paletteStops);
|
|
429
|
+
pointColorScale = (val: unknown) => {
|
|
430
|
+
const v = Number(val);
|
|
431
|
+
return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
// nominal / ordinal
|
|
436
|
+
const seen = new Set<string>();
|
|
437
|
+
for (const row of pts.data) {
|
|
438
|
+
const raw = row[pts.color.field];
|
|
439
|
+
if (raw != null) {
|
|
440
|
+
const cat = String(raw);
|
|
441
|
+
if (!seen.has(cat)) {
|
|
442
|
+
seen.add(cat);
|
|
443
|
+
pointCategories.push(cat);
|
|
444
|
+
pointCategoryColors.set(
|
|
445
|
+
cat,
|
|
446
|
+
CATEGORICAL_PALETTE[(pointCategories.length - 1) % CATEGORICAL_PALETTE.length],
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
pointColorScale = (val: unknown) => {
|
|
452
|
+
if (val == null) return CATEGORICAL_PALETTE[0];
|
|
453
|
+
return pointCategoryColors.get(String(val)) ?? CATEGORICAL_PALETTE[0];
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const defaultFill = CATEGORICAL_PALETTE[0];
|
|
459
|
+
let animIndex = 0;
|
|
460
|
+
|
|
461
|
+
for (let i = 0; i < pts.data.length; i++) {
|
|
462
|
+
const row = pts.data[i];
|
|
463
|
+
const lon = Number(row[lonField]);
|
|
464
|
+
const lat = Number(row[latField]);
|
|
465
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
|
466
|
+
|
|
467
|
+
const projected = projection([lon, lat]);
|
|
468
|
+
if (!projected) {
|
|
469
|
+
compileWarnings.push({
|
|
470
|
+
code: 'POINT_NULL_PROJECTION',
|
|
471
|
+
message: `Point at [${lon}, ${lat}] projects to null and was dropped.`,
|
|
472
|
+
context: { lon, lat },
|
|
473
|
+
});
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
|
|
478
|
+
const fill =
|
|
479
|
+
pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
|
|
480
|
+
const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
|
|
481
|
+
const stroke = isDarkMode ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.8)';
|
|
482
|
+
|
|
483
|
+
pointMarks.push({
|
|
484
|
+
type: 'map-point',
|
|
485
|
+
cx: projected[0],
|
|
486
|
+
cy: projected[1],
|
|
487
|
+
r,
|
|
488
|
+
fill,
|
|
489
|
+
stroke,
|
|
490
|
+
strokeWidth: 1,
|
|
491
|
+
key,
|
|
492
|
+
data: row as Record<string, unknown>,
|
|
493
|
+
aria: { role: 'img', label: key },
|
|
494
|
+
animationIndex: animIndex++,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Point categorical legend
|
|
499
|
+
if (
|
|
500
|
+
pts.color &&
|
|
501
|
+
pts.color.type !== 'quantitative' &&
|
|
502
|
+
pointCategories.length > 0 &&
|
|
503
|
+
showLegend
|
|
504
|
+
) {
|
|
505
|
+
const labelStyle: TextStyle = {
|
|
506
|
+
fontFamily: theme.fonts.family,
|
|
507
|
+
fontSize: theme.fonts.sizes.small,
|
|
508
|
+
fontWeight: theme.fonts.weights.normal,
|
|
509
|
+
fill: theme.colors.text,
|
|
510
|
+
lineHeight: 1.2,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const entries: LegendEntry[] = pointCategories.map((cat) => ({
|
|
514
|
+
label: cat,
|
|
515
|
+
color: pointCategoryColors.get(cat)!,
|
|
516
|
+
shape: 'circle' as const,
|
|
517
|
+
}));
|
|
518
|
+
|
|
519
|
+
// Position below the choropleth legend (or at the legend position if none)
|
|
520
|
+
const choroplethLegendHeight = continuousLegend
|
|
521
|
+
? continuousLegend.bounds.height
|
|
522
|
+
: categoricalLegend
|
|
523
|
+
? categoricalLegend.bounds.height
|
|
524
|
+
: 0;
|
|
525
|
+
const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
|
|
526
|
+
const legendX = fullArea.x;
|
|
527
|
+
const legendY =
|
|
528
|
+
fullArea.y +
|
|
529
|
+
mapAreaHeight +
|
|
530
|
+
legendReserveGap +
|
|
531
|
+
choroplethLegendHeight +
|
|
532
|
+
choroplethLegendGap;
|
|
533
|
+
const legendWidth = fullArea.width;
|
|
534
|
+
const swatchSize = 10;
|
|
535
|
+
const swatchGap = 6;
|
|
536
|
+
const entryGap = 16;
|
|
537
|
+
|
|
538
|
+
pointCategoricalLegend = {
|
|
539
|
+
type: 'categorical',
|
|
540
|
+
position: 'bottom',
|
|
541
|
+
bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
|
|
542
|
+
labelStyle,
|
|
543
|
+
entries,
|
|
544
|
+
swatchSize,
|
|
545
|
+
swatchGap,
|
|
546
|
+
entryGap,
|
|
547
|
+
swatchChipFill: isDarkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Point continuous legend (quantitative color)
|
|
552
|
+
if (pts.color && pts.color.type === 'quantitative' && showLegend) {
|
|
553
|
+
const ptColorValues: number[] = [];
|
|
554
|
+
for (const row of pts.data) {
|
|
555
|
+
const v = row[pts.color.field];
|
|
556
|
+
if (v != null) {
|
|
557
|
+
const n = Number(v);
|
|
558
|
+
if (Number.isFinite(n)) ptColorValues.push(n);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const ptLegendChannel: EncodingChannel = {
|
|
562
|
+
...pts.color,
|
|
563
|
+
scale: { ...pts.color.scale, type: 'quantile' },
|
|
564
|
+
};
|
|
565
|
+
const ptContinuousContent = computeContinuousLegendContentForChannel(
|
|
566
|
+
ptColorValues,
|
|
567
|
+
ptLegendChannel,
|
|
568
|
+
theme,
|
|
569
|
+
fullArea.width,
|
|
570
|
+
);
|
|
571
|
+
if (ptContinuousContent) {
|
|
572
|
+
const labelStyle: TextStyle = {
|
|
573
|
+
fontFamily: theme.fonts.family,
|
|
574
|
+
fontSize: theme.fonts.sizes.small,
|
|
575
|
+
fontWeight: theme.fonts.weights.normal,
|
|
576
|
+
fill: theme.colors.text,
|
|
577
|
+
lineHeight: 1.3,
|
|
578
|
+
fontVariant: 'tabular-nums',
|
|
579
|
+
};
|
|
580
|
+
const choroplethLegendHeight = continuousLegend
|
|
581
|
+
? continuousLegend.bounds.height
|
|
582
|
+
: categoricalLegend
|
|
583
|
+
? categoricalLegend.bounds.height
|
|
584
|
+
: 0;
|
|
585
|
+
const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
|
|
586
|
+
const legendX = fullArea.x;
|
|
587
|
+
const legendY =
|
|
588
|
+
fullArea.y +
|
|
589
|
+
mapAreaHeight +
|
|
590
|
+
legendReserveGap +
|
|
591
|
+
choroplethLegendHeight +
|
|
592
|
+
choroplethLegendGap;
|
|
593
|
+
const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
594
|
+
const ptLegendHeight =
|
|
595
|
+
ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
|
|
596
|
+
const bounds = {
|
|
597
|
+
x: legendX,
|
|
598
|
+
y: legendY,
|
|
599
|
+
width: ptContinuousContent.barWidth,
|
|
600
|
+
height: ptLegendHeight,
|
|
601
|
+
};
|
|
602
|
+
const bar = {
|
|
603
|
+
x: bounds.x,
|
|
604
|
+
y: bounds.y,
|
|
605
|
+
width: ptContinuousContent.barWidth,
|
|
606
|
+
height: ptContinuousContent.barHeight,
|
|
607
|
+
};
|
|
608
|
+
pointContinuousLegend = {
|
|
609
|
+
type: 'continuous' as const,
|
|
610
|
+
mode: ptContinuousContent.mode,
|
|
611
|
+
position: 'bottom',
|
|
612
|
+
bounds,
|
|
613
|
+
labelStyle,
|
|
614
|
+
bar,
|
|
615
|
+
colorStops: ptContinuousContent.colorStops,
|
|
616
|
+
bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
|
|
617
|
+
ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
|
|
618
|
+
labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
367
624
|
// 16. Tooltips
|
|
368
625
|
const tooltipChannels = mapSpec.encoding.tooltip
|
|
369
626
|
? Array.isArray(mapSpec.encoding.tooltip)
|
|
@@ -388,12 +645,12 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
388
645
|
});
|
|
389
646
|
}
|
|
390
647
|
}
|
|
391
|
-
} else if (isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
648
|
+
} else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
392
649
|
fields.push({
|
|
393
650
|
label: colorEncoding.title ?? colorEncoding.field,
|
|
394
651
|
value: formatter(Number(mark.data[colorEncoding.field])),
|
|
395
652
|
});
|
|
396
|
-
} else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
653
|
+
} else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
397
654
|
fields.push({
|
|
398
655
|
label: colorEncoding.title ?? colorEncoding.field,
|
|
399
656
|
value: String(mark.data[colorEncoding.field]),
|
|
@@ -402,14 +659,79 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
402
659
|
tooltipDescriptors.set(String(mark.id), { fields });
|
|
403
660
|
}
|
|
404
661
|
|
|
662
|
+
// Point tooltips (keyed with 'point:' prefix to disambiguate from feature IDs)
|
|
663
|
+
if (mapSpec.points && pointMarks.length > 0) {
|
|
664
|
+
const ptTooltipChannels = mapSpec.points.tooltip
|
|
665
|
+
? Array.isArray(mapSpec.points.tooltip)
|
|
666
|
+
? mapSpec.points.tooltip
|
|
667
|
+
: [mapSpec.points.tooltip]
|
|
668
|
+
: null;
|
|
669
|
+
|
|
670
|
+
for (const pm of pointMarks) {
|
|
671
|
+
const fields: TooltipField[] = [];
|
|
672
|
+
if (ptTooltipChannels) {
|
|
673
|
+
for (const ch of ptTooltipChannels) {
|
|
674
|
+
const val = pm.data[ch.field];
|
|
675
|
+
if (val != null) {
|
|
676
|
+
fields.push({
|
|
677
|
+
label: ch.title ?? ch.field,
|
|
678
|
+
value: ch.type === 'quantitative' ? formatter(Number(val)) : String(val),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
// Auto-populate from key, color, and size channels
|
|
684
|
+
const pts = mapSpec.points!;
|
|
685
|
+
if (pts.key) {
|
|
686
|
+
const val = pm.data[pts.key.field];
|
|
687
|
+
if (val != null)
|
|
688
|
+
fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
|
|
689
|
+
}
|
|
690
|
+
if (pts.color) {
|
|
691
|
+
const val = pm.data[pts.color.field];
|
|
692
|
+
if (val != null)
|
|
693
|
+
fields.push({
|
|
694
|
+
label: pts.color.title ?? pts.color.field,
|
|
695
|
+
value: pts.color.type === 'quantitative' ? formatter(Number(val)) : String(val),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
if (pts.size) {
|
|
699
|
+
const val = pm.data[pts.size.field];
|
|
700
|
+
if (val != null)
|
|
701
|
+
fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (fields.length > 0) {
|
|
705
|
+
tooltipDescriptors.set(`point:${pm.key}`, { fields });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
405
710
|
// 17. Accessibility
|
|
711
|
+
const a11yDataField = colorEncoding?.field;
|
|
406
712
|
const a11y = {
|
|
407
|
-
altText:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
713
|
+
altText:
|
|
714
|
+
pointMarks.length > 0
|
|
715
|
+
? colorEncoding
|
|
716
|
+
? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data and ${pointMarks.length} point markers`
|
|
717
|
+
: `Map showing ${geoFeatures.length} regions with ${pointMarks.length} point markers`
|
|
718
|
+
: colorEncoding
|
|
719
|
+
? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data`
|
|
720
|
+
: `Map showing ${geoFeatures.length} regions`,
|
|
721
|
+
dataTableFallback: [
|
|
722
|
+
...featureMarks
|
|
723
|
+
.filter((m) => m.data)
|
|
724
|
+
.map((m) => [
|
|
725
|
+
m.name ?? String(m.id),
|
|
726
|
+
a11yDataField ? String(m.data?.[a11yDataField] ?? '') : '',
|
|
727
|
+
]),
|
|
728
|
+
...pointMarks.map((pm) => [
|
|
729
|
+
pm.key,
|
|
730
|
+
mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? '') : '',
|
|
731
|
+
]),
|
|
732
|
+
],
|
|
411
733
|
role: 'img' as const,
|
|
412
|
-
keyboardNavigable: featureMarks.length > 0,
|
|
734
|
+
keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0,
|
|
413
735
|
};
|
|
414
736
|
|
|
415
737
|
// 18. Resolve focus
|
|
@@ -454,6 +776,9 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
454
776
|
borders,
|
|
455
777
|
continuousLegend,
|
|
456
778
|
categoricalLegend,
|
|
779
|
+
pointMarks,
|
|
780
|
+
pointCategoricalLegend,
|
|
781
|
+
pointContinuousLegend,
|
|
457
782
|
tooltipDescriptors,
|
|
458
783
|
a11y,
|
|
459
784
|
theme,
|
|
@@ -470,6 +795,65 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
470
795
|
};
|
|
471
796
|
}
|
|
472
797
|
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Basemap-only marks (neutral fill, no color encoding)
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
interface BasemapOptions {
|
|
803
|
+
geoFeatures: GeoJSON.Feature[];
|
|
804
|
+
pathGen: ReturnType<typeof geoPath>;
|
|
805
|
+
neutralFill: string;
|
|
806
|
+
neutralStroke: string;
|
|
807
|
+
droppedFeatures: string[];
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function buildBasemapMarks(opts: BasemapOptions): MapFeatureMark[] {
|
|
811
|
+
const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
|
|
812
|
+
const marks: MapFeatureMark[] = [];
|
|
813
|
+
let animIndex = 0;
|
|
814
|
+
|
|
815
|
+
for (const feat of geoFeatures) {
|
|
816
|
+
const pathD = pathGen(feat);
|
|
817
|
+
if (!pathD) {
|
|
818
|
+
const name =
|
|
819
|
+
((feat.properties as Record<string, unknown> | null)?.name as string) ?? String(feat.id);
|
|
820
|
+
droppedFeatures.push(name);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const featureId = feat.id as string | number;
|
|
825
|
+
const props = feat.properties as Record<string, unknown> | null;
|
|
826
|
+
const name = (props?.name as string) ?? (props?.NAME as string);
|
|
827
|
+
|
|
828
|
+
const b = pathGen.bounds(feat);
|
|
829
|
+
const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
|
|
830
|
+
const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
|
|
831
|
+
const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
|
|
832
|
+
const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
|
|
833
|
+
const bounds = { x: bx, y: by, width: bw, height: bh };
|
|
834
|
+
const rawCentroid = pathGen.centroid(feat);
|
|
835
|
+
const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
|
|
836
|
+
const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
|
|
837
|
+
|
|
838
|
+
marks.push({
|
|
839
|
+
type: 'map-feature',
|
|
840
|
+
path: pathD,
|
|
841
|
+
fill: neutralFill,
|
|
842
|
+
stroke: neutralStroke,
|
|
843
|
+
strokeWidth: 0.5,
|
|
844
|
+
id: featureId,
|
|
845
|
+
name,
|
|
846
|
+
data: null,
|
|
847
|
+
aria: { role: 'img', label: name ?? String(featureId) },
|
|
848
|
+
animationIndex: animIndex++,
|
|
849
|
+
bounds,
|
|
850
|
+
centroid: [cx, cy],
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return marks;
|
|
855
|
+
}
|
|
856
|
+
|
|
473
857
|
// ---------------------------------------------------------------------------
|
|
474
858
|
// Quantitative marks (sequential color scale)
|
|
475
859
|
// ---------------------------------------------------------------------------
|
|
@@ -834,6 +1218,9 @@ function emptyLayout(
|
|
|
834
1218
|
},
|
|
835
1219
|
continuousLegend: null,
|
|
836
1220
|
categoricalLegend: null,
|
|
1221
|
+
pointMarks: [],
|
|
1222
|
+
pointCategoricalLegend: null,
|
|
1223
|
+
pointContinuousLegend: null,
|
|
837
1224
|
tooltipDescriptors: new Map(),
|
|
838
1225
|
a11y: {
|
|
839
1226
|
altText: 'Empty map',
|
package/src/geo/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
LegendConfig,
|
|
5
5
|
MapEncoding,
|
|
6
6
|
MapGeo,
|
|
7
|
+
MapPointsLayer,
|
|
7
8
|
ThemeConfig,
|
|
8
9
|
} from '@opendata-ai/openchart-core';
|
|
9
10
|
|
|
@@ -21,4 +22,5 @@ export interface NormalizedMapSpec {
|
|
|
21
22
|
watermark: boolean;
|
|
22
23
|
animation?: AnimationSpec;
|
|
23
24
|
valueFormat?: string;
|
|
25
|
+
points?: MapPointsLayer;
|
|
24
26
|
}
|