@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.4
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 +428 -36
- 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 +87 -11
- package/src/geo/__tests__/compile-map.test.ts +211 -0
- package/src/geo/compile-map.ts +453 -29
- 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;
|
|
@@ -232,18 +247,27 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
232
247
|
}
|
|
233
248
|
}
|
|
234
249
|
|
|
235
|
-
// 11. Join data to features
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
250
|
+
// 11. Join data to features (skip when no key channel, e.g. basemap-only mode)
|
|
251
|
+
let joined = new Map<string | number, Record<string, unknown>>();
|
|
252
|
+
if (mapSpec.encoding.key) {
|
|
253
|
+
const featureObjs = geoFeatures.map((f) => ({
|
|
254
|
+
id: f.id as string | number,
|
|
255
|
+
properties: (f.properties ?? {}) as Record<string, unknown>,
|
|
256
|
+
}));
|
|
257
|
+
const joinResult = joinDataToFeatures(
|
|
258
|
+
featureObjs,
|
|
259
|
+
mapSpec.data,
|
|
260
|
+
mapSpec.encoding.key.field,
|
|
261
|
+
mapSpec.geo.idField,
|
|
262
|
+
);
|
|
263
|
+
joined = joinResult.joined;
|
|
264
|
+
// In basemap-only mode (empty data + points layer), all features are intentionally unmatched
|
|
265
|
+
const filteredJoinWarnings =
|
|
266
|
+
mapSpec.data.length === 0 && mapSpec.points
|
|
267
|
+
? joinResult.warnings.filter((w) => w.code !== 'UNMATCHED_FEATURES')
|
|
268
|
+
: joinResult.warnings;
|
|
269
|
+
compileWarnings.push(...filteredJoinWarnings);
|
|
270
|
+
}
|
|
247
271
|
|
|
248
272
|
// 12. Check for null-projecting features (albersUsa drops territories)
|
|
249
273
|
const droppedFeatures: string[] = [];
|
|
@@ -257,13 +281,22 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
257
281
|
let continuousLegend: ContinuousLegendLayout | null = null;
|
|
258
282
|
let categoricalLegend: CategoricalLegendLayout | null = null;
|
|
259
283
|
|
|
260
|
-
if (
|
|
284
|
+
if (!colorEncoding) {
|
|
285
|
+
// Basemap-only mode: all features get neutral fill, no legend
|
|
286
|
+
featureMarks = buildBasemapMarks({
|
|
287
|
+
geoFeatures,
|
|
288
|
+
pathGen,
|
|
289
|
+
neutralFill,
|
|
290
|
+
neutralStroke,
|
|
291
|
+
droppedFeatures,
|
|
292
|
+
});
|
|
293
|
+
} else if (isQuantitative) {
|
|
261
294
|
const result = buildQuantitativeMarks({
|
|
262
295
|
geoFeatures,
|
|
263
296
|
pathGen,
|
|
264
297
|
joined,
|
|
265
298
|
colorField: colorEncoding.field,
|
|
266
|
-
palette:
|
|
299
|
+
palette: colorEncoding.scale?.scheme ?? 'blue',
|
|
267
300
|
isDarkMode: !!isDarkMode,
|
|
268
301
|
neutralFill,
|
|
269
302
|
neutralStroke,
|
|
@@ -319,6 +352,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
319
352
|
pathGen,
|
|
320
353
|
joined,
|
|
321
354
|
colorField: colorEncoding.field,
|
|
355
|
+
colorScale: colorEncoding.scale,
|
|
322
356
|
isDarkMode: !!isDarkMode,
|
|
323
357
|
neutralFill,
|
|
324
358
|
neutralStroke,
|
|
@@ -364,6 +398,255 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
364
398
|
const outlineStroke = isDarkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.3)';
|
|
365
399
|
const borders: MapBorders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
|
|
366
400
|
|
|
401
|
+
// 15b. Point marks (symbol layer above choropleth)
|
|
402
|
+
const pointMarks: MapPointMark[] = [];
|
|
403
|
+
let pointCategoricalLegend: CategoricalLegendLayout | null = null;
|
|
404
|
+
let pointContinuousLegend: ContinuousLegendLayout | null = null;
|
|
405
|
+
|
|
406
|
+
if (mapSpec.points) {
|
|
407
|
+
const pts = mapSpec.points;
|
|
408
|
+
const lonField = pts.longitude.field;
|
|
409
|
+
const latField = pts.latitude.field;
|
|
410
|
+
const keyChannel = pts.key;
|
|
411
|
+
|
|
412
|
+
// Size scale
|
|
413
|
+
const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
|
|
414
|
+
|
|
415
|
+
// Color scale (independent from choropleth)
|
|
416
|
+
let pointColorScale: ((val: unknown) => string) | null = null;
|
|
417
|
+
const pointCategories: string[] = [];
|
|
418
|
+
const pointCategoryColors = new Map<string, string>();
|
|
419
|
+
|
|
420
|
+
if (pts.color) {
|
|
421
|
+
if (pts.color.type === 'quantitative') {
|
|
422
|
+
const colorValues: number[] = [];
|
|
423
|
+
for (const row of pts.data) {
|
|
424
|
+
const raw = row[pts.color.field];
|
|
425
|
+
if (raw != null) {
|
|
426
|
+
const v = Number(raw);
|
|
427
|
+
if (Number.isFinite(v)) colorValues.push(v);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (colorValues.length > 0) {
|
|
431
|
+
const palette = pts.color.scale?.scheme ?? 'blue';
|
|
432
|
+
const paletteStops = [...(SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue)];
|
|
433
|
+
const qScale = scaleQuantile<string>().domain(colorValues).range(paletteStops);
|
|
434
|
+
pointColorScale = (val: unknown) => {
|
|
435
|
+
const v = Number(val);
|
|
436
|
+
return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
// nominal / ordinal: honor scale.domain (category order) and scale.range (explicit colors)
|
|
441
|
+
const explicitDomain = pts.color.scale?.domain as string[] | undefined;
|
|
442
|
+
const explicitRange = pts.color.scale?.range as string[] | undefined;
|
|
443
|
+
|
|
444
|
+
if (explicitDomain && Array.isArray(explicitDomain)) {
|
|
445
|
+
for (const cat of explicitDomain) {
|
|
446
|
+
pointCategories.push(String(cat));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Also pick up any categories in the data that aren't in the explicit domain
|
|
450
|
+
const seen = new Set<string>(pointCategories);
|
|
451
|
+
for (const row of pts.data) {
|
|
452
|
+
const raw = row[pts.color.field];
|
|
453
|
+
if (raw != null) {
|
|
454
|
+
const cat = String(raw);
|
|
455
|
+
if (!seen.has(cat)) {
|
|
456
|
+
seen.add(cat);
|
|
457
|
+
pointCategories.push(cat);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const colorSource =
|
|
463
|
+
explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
|
|
464
|
+
for (let i = 0; i < pointCategories.length; i++) {
|
|
465
|
+
pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
pointColorScale = (val: unknown) => {
|
|
469
|
+
if (val == null) return colorSource[0];
|
|
470
|
+
return pointCategoryColors.get(String(val)) ?? colorSource[0];
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const defaultFill = CATEGORICAL_PALETTE[0];
|
|
476
|
+
const pointOpacity = pts.opacity ?? 0.65;
|
|
477
|
+
let animIndex = 0;
|
|
478
|
+
const droppedPoints: Array<[number, number]> = [];
|
|
479
|
+
|
|
480
|
+
for (let i = 0; i < pts.data.length; i++) {
|
|
481
|
+
const row = pts.data[i];
|
|
482
|
+
const lon = Number(row[lonField]);
|
|
483
|
+
const lat = Number(row[latField]);
|
|
484
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
|
485
|
+
|
|
486
|
+
const projected = projection([lon, lat]);
|
|
487
|
+
if (!projected) {
|
|
488
|
+
droppedPoints.push([lon, lat]);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
|
|
493
|
+
const fill =
|
|
494
|
+
pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
|
|
495
|
+
const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
|
|
496
|
+
const stroke = isDarkMode ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.8)';
|
|
497
|
+
|
|
498
|
+
pointMarks.push({
|
|
499
|
+
type: 'map-point',
|
|
500
|
+
cx: projected[0],
|
|
501
|
+
cy: projected[1],
|
|
502
|
+
r,
|
|
503
|
+
fill,
|
|
504
|
+
stroke,
|
|
505
|
+
strokeWidth: 1,
|
|
506
|
+
fillOpacity: pointOpacity,
|
|
507
|
+
key,
|
|
508
|
+
data: row as Record<string, unknown>,
|
|
509
|
+
aria: { role: 'img', label: key },
|
|
510
|
+
animationIndex: animIndex++,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (droppedPoints.length > 0) {
|
|
515
|
+
const sample = droppedPoints
|
|
516
|
+
.slice(0, 3)
|
|
517
|
+
.map(([lo, la]) => `[${lo}, ${la}]`)
|
|
518
|
+
.join(', ');
|
|
519
|
+
compileWarnings.push({
|
|
520
|
+
code: 'POINT_NULL_PROJECTION',
|
|
521
|
+
message:
|
|
522
|
+
`${droppedPoints.length} point(s) project to null and were dropped (e.g. ${sample}). ` +
|
|
523
|
+
'This is common with albersUsa which excludes territories. ' +
|
|
524
|
+
'Consider using projection: "mercator" or "equalEarth".',
|
|
525
|
+
context: { count: droppedPoints.length, samples: droppedPoints.slice(0, 5) },
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Point categorical legend
|
|
530
|
+
if (
|
|
531
|
+
pts.color &&
|
|
532
|
+
pts.color.type !== 'quantitative' &&
|
|
533
|
+
pointCategories.length > 0 &&
|
|
534
|
+
showLegend
|
|
535
|
+
) {
|
|
536
|
+
const labelStyle: TextStyle = {
|
|
537
|
+
fontFamily: theme.fonts.family,
|
|
538
|
+
fontSize: theme.fonts.sizes.small,
|
|
539
|
+
fontWeight: theme.fonts.weights.normal,
|
|
540
|
+
fill: theme.colors.text,
|
|
541
|
+
lineHeight: 1.2,
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const entries: LegendEntry[] = pointCategories.map((cat) => ({
|
|
545
|
+
label: cat,
|
|
546
|
+
color: pointCategoryColors.get(cat)!,
|
|
547
|
+
shape: 'circle' as const,
|
|
548
|
+
}));
|
|
549
|
+
|
|
550
|
+
// Position below the map. Only offset by choropleth legend height when
|
|
551
|
+
// the choropleth legend is also at the bottom (not 'top').
|
|
552
|
+
const bottomChoroplethHeight =
|
|
553
|
+
legendPosition === 'bottom'
|
|
554
|
+
? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
|
|
555
|
+
: 0;
|
|
556
|
+
const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
|
|
557
|
+
const legendX = fullArea.x;
|
|
558
|
+
const legendY =
|
|
559
|
+
fullArea.y +
|
|
560
|
+
mapAreaHeight +
|
|
561
|
+
legendReserveGap +
|
|
562
|
+
bottomChoroplethHeight +
|
|
563
|
+
choroplethLegendGap;
|
|
564
|
+
const legendWidth = fullArea.width;
|
|
565
|
+
const swatchSize = 10;
|
|
566
|
+
const swatchGap = 6;
|
|
567
|
+
const entryGap = 16;
|
|
568
|
+
|
|
569
|
+
pointCategoricalLegend = {
|
|
570
|
+
type: 'categorical',
|
|
571
|
+
position: 'bottom',
|
|
572
|
+
bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
|
|
573
|
+
labelStyle,
|
|
574
|
+
entries,
|
|
575
|
+
swatchSize,
|
|
576
|
+
swatchGap,
|
|
577
|
+
entryGap,
|
|
578
|
+
swatchChipFill: isDarkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Point continuous legend (quantitative color)
|
|
583
|
+
if (pts.color && pts.color.type === 'quantitative' && showLegend) {
|
|
584
|
+
const ptColorValues: number[] = [];
|
|
585
|
+
for (const row of pts.data) {
|
|
586
|
+
const v = row[pts.color.field];
|
|
587
|
+
if (v != null) {
|
|
588
|
+
const n = Number(v);
|
|
589
|
+
if (Number.isFinite(n)) ptColorValues.push(n);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const ptLegendChannel: EncodingChannel = {
|
|
593
|
+
...pts.color,
|
|
594
|
+
scale: { ...pts.color.scale, type: 'quantile' },
|
|
595
|
+
};
|
|
596
|
+
const ptContinuousContent = computeContinuousLegendContentForChannel(
|
|
597
|
+
ptColorValues,
|
|
598
|
+
ptLegendChannel,
|
|
599
|
+
theme,
|
|
600
|
+
fullArea.width,
|
|
601
|
+
);
|
|
602
|
+
if (ptContinuousContent) {
|
|
603
|
+
const labelStyle: TextStyle = {
|
|
604
|
+
fontFamily: theme.fonts.family,
|
|
605
|
+
fontSize: theme.fonts.sizes.small,
|
|
606
|
+
fontWeight: theme.fonts.weights.normal,
|
|
607
|
+
fill: theme.colors.text,
|
|
608
|
+
lineHeight: 1.3,
|
|
609
|
+
fontVariant: 'tabular-nums',
|
|
610
|
+
};
|
|
611
|
+
const bottomChoroplethH =
|
|
612
|
+
legendPosition === 'bottom'
|
|
613
|
+
? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
|
|
614
|
+
: 0;
|
|
615
|
+
const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
|
|
616
|
+
const legendX = fullArea.x;
|
|
617
|
+
const legendY =
|
|
618
|
+
fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
|
|
619
|
+
const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
620
|
+
const ptLegendHeight =
|
|
621
|
+
ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
|
|
622
|
+
const bounds = {
|
|
623
|
+
x: legendX,
|
|
624
|
+
y: legendY,
|
|
625
|
+
width: ptContinuousContent.barWidth,
|
|
626
|
+
height: ptLegendHeight,
|
|
627
|
+
};
|
|
628
|
+
const bar = {
|
|
629
|
+
x: bounds.x,
|
|
630
|
+
y: bounds.y,
|
|
631
|
+
width: ptContinuousContent.barWidth,
|
|
632
|
+
height: ptContinuousContent.barHeight,
|
|
633
|
+
};
|
|
634
|
+
pointContinuousLegend = {
|
|
635
|
+
type: 'continuous' as const,
|
|
636
|
+
mode: ptContinuousContent.mode,
|
|
637
|
+
position: 'bottom',
|
|
638
|
+
bounds,
|
|
639
|
+
labelStyle,
|
|
640
|
+
bar,
|
|
641
|
+
colorStops: ptContinuousContent.colorStops,
|
|
642
|
+
bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
|
|
643
|
+
ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
|
|
644
|
+
labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
367
650
|
// 16. Tooltips
|
|
368
651
|
const tooltipChannels = mapSpec.encoding.tooltip
|
|
369
652
|
? Array.isArray(mapSpec.encoding.tooltip)
|
|
@@ -388,12 +671,12 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
388
671
|
});
|
|
389
672
|
}
|
|
390
673
|
}
|
|
391
|
-
} else if (isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
674
|
+
} else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
392
675
|
fields.push({
|
|
393
676
|
label: colorEncoding.title ?? colorEncoding.field,
|
|
394
677
|
value: formatter(Number(mark.data[colorEncoding.field])),
|
|
395
678
|
});
|
|
396
|
-
} else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
679
|
+
} else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
397
680
|
fields.push({
|
|
398
681
|
label: colorEncoding.title ?? colorEncoding.field,
|
|
399
682
|
value: String(mark.data[colorEncoding.field]),
|
|
@@ -402,14 +685,79 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
402
685
|
tooltipDescriptors.set(String(mark.id), { fields });
|
|
403
686
|
}
|
|
404
687
|
|
|
688
|
+
// Point tooltips (keyed with 'point:' prefix to disambiguate from feature IDs)
|
|
689
|
+
if (mapSpec.points && pointMarks.length > 0) {
|
|
690
|
+
const ptTooltipChannels = mapSpec.points.tooltip
|
|
691
|
+
? Array.isArray(mapSpec.points.tooltip)
|
|
692
|
+
? mapSpec.points.tooltip
|
|
693
|
+
: [mapSpec.points.tooltip]
|
|
694
|
+
: null;
|
|
695
|
+
|
|
696
|
+
for (const pm of pointMarks) {
|
|
697
|
+
const fields: TooltipField[] = [];
|
|
698
|
+
if (ptTooltipChannels) {
|
|
699
|
+
for (const ch of ptTooltipChannels) {
|
|
700
|
+
const val = pm.data[ch.field];
|
|
701
|
+
if (val != null) {
|
|
702
|
+
fields.push({
|
|
703
|
+
label: ch.title ?? ch.field,
|
|
704
|
+
value: ch.type === 'quantitative' ? formatter(Number(val)) : String(val),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
} else {
|
|
709
|
+
// Auto-populate from key, color, and size channels
|
|
710
|
+
const pts = mapSpec.points!;
|
|
711
|
+
if (pts.key) {
|
|
712
|
+
const val = pm.data[pts.key.field];
|
|
713
|
+
if (val != null)
|
|
714
|
+
fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
|
|
715
|
+
}
|
|
716
|
+
if (pts.color) {
|
|
717
|
+
const val = pm.data[pts.color.field];
|
|
718
|
+
if (val != null)
|
|
719
|
+
fields.push({
|
|
720
|
+
label: pts.color.title ?? pts.color.field,
|
|
721
|
+
value: pts.color.type === 'quantitative' ? formatter(Number(val)) : String(val),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
if (pts.size) {
|
|
725
|
+
const val = pm.data[pts.size.field];
|
|
726
|
+
if (val != null)
|
|
727
|
+
fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (fields.length > 0) {
|
|
731
|
+
tooltipDescriptors.set(`point:${pm.key}`, { fields });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
405
736
|
// 17. Accessibility
|
|
737
|
+
const a11yDataField = colorEncoding?.field;
|
|
406
738
|
const a11y = {
|
|
407
|
-
altText:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
739
|
+
altText:
|
|
740
|
+
pointMarks.length > 0
|
|
741
|
+
? colorEncoding
|
|
742
|
+
? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data and ${pointMarks.length} point markers`
|
|
743
|
+
: `Map showing ${geoFeatures.length} regions with ${pointMarks.length} point markers`
|
|
744
|
+
: colorEncoding
|
|
745
|
+
? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data`
|
|
746
|
+
: `Map showing ${geoFeatures.length} regions`,
|
|
747
|
+
dataTableFallback: [
|
|
748
|
+
...featureMarks
|
|
749
|
+
.filter((m) => m.data)
|
|
750
|
+
.map((m) => [
|
|
751
|
+
m.name ?? String(m.id),
|
|
752
|
+
a11yDataField ? String(m.data?.[a11yDataField] ?? '') : '',
|
|
753
|
+
]),
|
|
754
|
+
...pointMarks.map((pm) => [
|
|
755
|
+
pm.key,
|
|
756
|
+
mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? '') : '',
|
|
757
|
+
]),
|
|
758
|
+
],
|
|
411
759
|
role: 'img' as const,
|
|
412
|
-
keyboardNavigable: featureMarks.length > 0,
|
|
760
|
+
keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0,
|
|
413
761
|
};
|
|
414
762
|
|
|
415
763
|
// 18. Resolve focus
|
|
@@ -454,6 +802,9 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
454
802
|
borders,
|
|
455
803
|
continuousLegend,
|
|
456
804
|
categoricalLegend,
|
|
805
|
+
pointMarks,
|
|
806
|
+
pointCategoricalLegend,
|
|
807
|
+
pointContinuousLegend,
|
|
457
808
|
tooltipDescriptors,
|
|
458
809
|
a11y,
|
|
459
810
|
theme,
|
|
@@ -470,6 +821,65 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
470
821
|
};
|
|
471
822
|
}
|
|
472
823
|
|
|
824
|
+
// ---------------------------------------------------------------------------
|
|
825
|
+
// Basemap-only marks (neutral fill, no color encoding)
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
|
|
828
|
+
interface BasemapOptions {
|
|
829
|
+
geoFeatures: GeoJSON.Feature[];
|
|
830
|
+
pathGen: ReturnType<typeof geoPath>;
|
|
831
|
+
neutralFill: string;
|
|
832
|
+
neutralStroke: string;
|
|
833
|
+
droppedFeatures: string[];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function buildBasemapMarks(opts: BasemapOptions): MapFeatureMark[] {
|
|
837
|
+
const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
|
|
838
|
+
const marks: MapFeatureMark[] = [];
|
|
839
|
+
let animIndex = 0;
|
|
840
|
+
|
|
841
|
+
for (const feat of geoFeatures) {
|
|
842
|
+
const pathD = pathGen(feat);
|
|
843
|
+
if (!pathD) {
|
|
844
|
+
const name =
|
|
845
|
+
((feat.properties as Record<string, unknown> | null)?.name as string) ?? String(feat.id);
|
|
846
|
+
droppedFeatures.push(name);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const featureId = feat.id as string | number;
|
|
851
|
+
const props = feat.properties as Record<string, unknown> | null;
|
|
852
|
+
const name = (props?.name as string) ?? (props?.NAME as string);
|
|
853
|
+
|
|
854
|
+
const b = pathGen.bounds(feat);
|
|
855
|
+
const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
|
|
856
|
+
const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
|
|
857
|
+
const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
|
|
858
|
+
const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
|
|
859
|
+
const bounds = { x: bx, y: by, width: bw, height: bh };
|
|
860
|
+
const rawCentroid = pathGen.centroid(feat);
|
|
861
|
+
const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
|
|
862
|
+
const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
|
|
863
|
+
|
|
864
|
+
marks.push({
|
|
865
|
+
type: 'map-feature',
|
|
866
|
+
path: pathD,
|
|
867
|
+
fill: neutralFill,
|
|
868
|
+
stroke: neutralStroke,
|
|
869
|
+
strokeWidth: 0.5,
|
|
870
|
+
id: featureId,
|
|
871
|
+
name,
|
|
872
|
+
data: null,
|
|
873
|
+
aria: { role: 'img', label: name ?? String(featureId) },
|
|
874
|
+
animationIndex: animIndex++,
|
|
875
|
+
bounds,
|
|
876
|
+
centroid: [cx, cy],
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return marks;
|
|
881
|
+
}
|
|
882
|
+
|
|
473
883
|
// ---------------------------------------------------------------------------
|
|
474
884
|
// Quantitative marks (sequential color scale)
|
|
475
885
|
// ---------------------------------------------------------------------------
|
|
@@ -592,6 +1002,7 @@ interface CategoricalOptions {
|
|
|
592
1002
|
pathGen: ReturnType<typeof geoPath>;
|
|
593
1003
|
joined: Map<string | number, Record<string, unknown>>;
|
|
594
1004
|
colorField: string;
|
|
1005
|
+
colorScale?: { domain?: unknown; range?: unknown };
|
|
595
1006
|
isDarkMode: boolean;
|
|
596
1007
|
neutralFill: string;
|
|
597
1008
|
neutralStroke: string;
|
|
@@ -612,6 +1023,7 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
612
1023
|
pathGen,
|
|
613
1024
|
joined,
|
|
614
1025
|
colorField,
|
|
1026
|
+
colorScale: scaleConfig,
|
|
615
1027
|
isDarkMode,
|
|
616
1028
|
neutralFill,
|
|
617
1029
|
neutralStroke,
|
|
@@ -623,9 +1035,15 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
623
1035
|
droppedFeatures,
|
|
624
1036
|
} = opts;
|
|
625
1037
|
|
|
626
|
-
// Collect unique categories
|
|
1038
|
+
// Collect unique categories: honor scale.domain for order, then append any unseen from data
|
|
627
1039
|
const categories: string[] = [];
|
|
628
|
-
const
|
|
1040
|
+
const explicitDomain = scaleConfig?.domain;
|
|
1041
|
+
if (explicitDomain && Array.isArray(explicitDomain)) {
|
|
1042
|
+
for (const cat of explicitDomain) {
|
|
1043
|
+
categories.push(String(cat));
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
const seen = new Set<string>(categories);
|
|
629
1047
|
for (const row of joined.values()) {
|
|
630
1048
|
const raw = row[colorField];
|
|
631
1049
|
if (raw != null) {
|
|
@@ -637,9 +1055,12 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
637
1055
|
}
|
|
638
1056
|
}
|
|
639
1057
|
|
|
1058
|
+
const explicitRange = scaleConfig?.range;
|
|
1059
|
+
const colorSource =
|
|
1060
|
+
explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
|
|
640
1061
|
const categoryColors = new Map<string, string>();
|
|
641
1062
|
for (let i = 0; i < categories.length; i++) {
|
|
642
|
-
categoryColors.set(categories[i],
|
|
1063
|
+
categoryColors.set(categories[i], colorSource[i % colorSource.length]);
|
|
643
1064
|
}
|
|
644
1065
|
|
|
645
1066
|
const marks: MapFeatureMark[] = [];
|
|
@@ -834,6 +1255,9 @@ function emptyLayout(
|
|
|
834
1255
|
},
|
|
835
1256
|
continuousLegend: null,
|
|
836
1257
|
categoricalLegend: null,
|
|
1258
|
+
pointMarks: [],
|
|
1259
|
+
pointCategoricalLegend: null,
|
|
1260
|
+
pointContinuousLegend: null,
|
|
837
1261
|
tooltipDescriptors: new Map(),
|
|
838
1262
|
a11y: {
|
|
839
1263
|
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
|
}
|