@opendata-ai/openchart-engine 8.0.0-rc.8 → 8.0.0-rc.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "8.0.0-rc.8",
3
+ "version": "8.0.0-rc.9",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "8.0.0-rc.8",
51
+ "@opendata-ai/openchart-core": "8.0.0-rc.9",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -554,6 +554,29 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
554
554
  ],
555
555
  ],
556
556
  "watermark": false,
557
+ "xInvert": {
558
+ "bottomData": 50,
559
+ "bottomPixel": 577,
560
+ "scaleType": "linear",
561
+ "topData": 0,
562
+ "topPixel": 36.942,
563
+ },
564
+ "yInvert": {
565
+ "bottomData": 0,
566
+ "bottomPixel": 60.9,
567
+ "domain": [
568
+ "A",
569
+ "B",
570
+ "C",
571
+ ],
572
+ "positions": [
573
+ 297.33805970149257,
574
+ 208.95000000000002,
575
+ 120.56194029850745,
576
+ ],
577
+ "topData": 0,
578
+ "topPixel": 357,
579
+ },
557
580
  }
558
581
  `;
559
582
 
@@ -1608,6 +1631,20 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1608
1631
  },
1609
1632
  "tooltipDescriptors": [],
1610
1633
  "watermark": false,
1634
+ "xInvert": {
1635
+ "bottomData": 1609459200000,
1636
+ "bottomPixel": 651.76115,
1637
+ "scaleType": "time",
1638
+ "topData": 1577836800000,
1639
+ "topPixel": 47,
1640
+ },
1641
+ "yInvert": {
1642
+ "bottomData": 40,
1643
+ "bottomPixel": 105.2,
1644
+ "scaleType": "linear",
1645
+ "topData": 8,
1646
+ "topPixel": 410.59999999999997,
1647
+ },
1611
1648
  }
1612
1649
  `;
1613
1650
 
@@ -2335,5 +2372,30 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
2335
2372
  ],
2336
2373
  ],
2337
2374
  "watermark": true,
2375
+ "xInvert": {
2376
+ "bottomData": 0,
2377
+ "bottomPixel": 577,
2378
+ "domain": [
2379
+ "A",
2380
+ "B",
2381
+ "C",
2382
+ "D",
2383
+ ],
2384
+ "positions": [
2385
+ 122.67875862068962,
2386
+ 246.30358620689654,
2387
+ 369.92841379310346,
2388
+ 493.55324137931035,
2389
+ ],
2390
+ "topData": 0,
2391
+ "topPixel": 39.232,
2392
+ },
2393
+ "yInvert": {
2394
+ "bottomData": 70,
2395
+ "bottomPixel": 60.9,
2396
+ "scaleType": "linear",
2397
+ "topData": 0,
2398
+ "topPixel": 297.2,
2399
+ },
2338
2400
  }
2339
2401
  `;
@@ -611,6 +611,10 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
611
611
  ...leaves[0],
612
612
  data: allData,
613
613
  chrome: layerSpec.chrome ?? leaves[0].chrome,
614
+ // Annotations come from the parent LayerSpec when present, otherwise
615
+ // the first flattened leaf's. Later leaves' annotations are silently
616
+ // dropped. The dual-axis path (compileLayerIndependent) explicitly
617
+ // clears the secondary axis spec's annotations.
614
618
  annotations: layerSpec.annotations ?? leaves[0].annotations,
615
619
  labels: layerSpec.labels ?? leaves[0].labels,
616
620
  legend: layerSpec.legend ?? leaves[0].legend,
package/src/compile.ts CHANGED
@@ -28,6 +28,7 @@ import type {
28
28
  ResolvedAnimation,
29
29
  ResolvedAnnotation,
30
30
  ResolvedTheme,
31
+ ScaleInvert,
31
32
  TableLayout,
32
33
  } from '@opendata-ai/openchart-core';
33
34
  import {
@@ -84,7 +85,7 @@ import { computeDimensions } from './layout/dimensions';
84
85
  import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
85
86
  import { computeGridlines } from './layout/gridlines';
86
87
  import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
87
- import { computeScales } from './layout/scales';
88
+ import { computeScales, type ResolvedScale } from './layout/scales';
88
89
  import { placeLegend } from './legend/compute';
89
90
  import { placeSizeLegend } from './legend/size';
90
91
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
@@ -187,6 +188,52 @@ function applySparklineDefaults(
187
188
  return { ...spec, markDef: newMarkDef };
188
189
  }
189
190
 
191
+ /**
192
+ * Extract scale inversion anchors from a resolved scale so the vanilla layer
193
+ * can convert pixel positions back to data-space values without holding the
194
+ * d3 scale object. Generalizes the YouDrawItYInvert precedent to any axis.
195
+ */
196
+ function resolveScaleInvert(resolved: ResolvedScale | undefined): ScaleInvert | undefined {
197
+ if (!resolved) return undefined;
198
+ const { scale, type } = resolved;
199
+
200
+ // Band / point scales: snap to nearest domain value
201
+ if (type === 'band' || type === 'point') {
202
+ const domain = (scale as { domain(): string[] }).domain() as (string | number)[];
203
+ const positions = domain.map((v) => {
204
+ const pos = (scale as (v: string | number) => number | undefined)(v) ?? 0;
205
+ return type === 'band' ? pos + (scale as { bandwidth(): number }).bandwidth() / 2 : pos;
206
+ });
207
+ const range = (scale as { range(): number[] }).range();
208
+ return {
209
+ topPixel: range[0],
210
+ bottomPixel: range[range.length - 1],
211
+ topData: 0,
212
+ bottomData: 0,
213
+ domain,
214
+ positions,
215
+ };
216
+ }
217
+
218
+ // Continuous scales (linear, time, log, pow, sqrt, symlog)
219
+ const invert = (scale as { invert?: (px: number) => unknown }).invert;
220
+ if (typeof invert !== 'function') return undefined;
221
+
222
+ const range = (scale as { range(): number[] }).range();
223
+ const topPx = range[0];
224
+ const bottomPx = range[range.length - 1];
225
+ const topVal = invert.call(scale, topPx);
226
+ const bottomVal = invert.call(scale, bottomPx);
227
+
228
+ // For time scales, convert Date to epoch ms
229
+ const topData = topVal instanceof Date ? +topVal : Number(topVal);
230
+ const bottomData = bottomVal instanceof Date ? +bottomVal : Number(bottomVal);
231
+ if (!Number.isFinite(topData) || !Number.isFinite(bottomData)) return undefined;
232
+
233
+ const scaleType = type as ScaleInvert['scaleType'];
234
+ return { topPixel: topPx, bottomPixel: bottomPx, topData, bottomData, scaleType };
235
+ }
236
+
190
237
  /**
191
238
  * Compile a chart spec into a ChartLayout.
192
239
  *
@@ -857,6 +904,10 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart
857
904
 
858
905
  const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
859
906
 
907
+ // Scale inversion anchors for edit-mode anchor drag (pixel -> data-space)
908
+ const xInvert = resolveScaleInvert(scales.x);
909
+ const yInvert = resolveScaleInvert(scales.y);
910
+
860
911
  return {
861
912
  area: chartArea,
862
913
  chrome,
@@ -890,6 +941,8 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart
890
941
  display: chartSpec.display,
891
942
  crosshair,
892
943
  measureText: options.measureText,
944
+ ...(xInvert ? { xInvert } : {}),
945
+ ...(yInvert ? { yInvert } : {}),
893
946
  };
894
947
  }
895
948
 
@@ -1000,5 +1000,129 @@ describe('compileMap', () => {
1000
1000
  expect(Number.isFinite(pm.cy)).toBe(true);
1001
1001
  }
1002
1002
  });
1003
+
1004
+ it('encoding.color legend: null suppresses the choropleth legend reserve (basemap-only)', () => {
1005
+ const base = {
1006
+ type: 'map',
1007
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1008
+ data: [] as Array<Record<string, unknown>>,
1009
+ points: {
1010
+ data: [{ lat: 34, lon: -118, name: 'LA', v: 10 }],
1011
+ longitude: { field: 'lon', type: 'quantitative' },
1012
+ latitude: { field: 'lat', type: 'quantitative' },
1013
+ color: { field: 'v', type: 'quantitative' },
1014
+ key: { field: 'name', type: 'nominal' },
1015
+ },
1016
+ theme: { spacing: { padding: 0 } },
1017
+ };
1018
+ const withLegendNull = compileMap(
1019
+ {
1020
+ ...base,
1021
+ encoding: {
1022
+ key: { field: 'id', type: 'nominal' },
1023
+ color: { field: 'id', type: 'nominal', legend: null },
1024
+ },
1025
+ },
1026
+ DEFAULT_OPTIONS,
1027
+ );
1028
+ const withoutLegendNull = compileMap(
1029
+ {
1030
+ ...base,
1031
+ encoding: {
1032
+ key: { field: 'id', type: 'nominal' },
1033
+ color: { field: 'id', type: 'nominal' },
1034
+ },
1035
+ },
1036
+ DEFAULT_OPTIONS,
1037
+ );
1038
+
1039
+ // legend: null frees the phantom choropleth swatch row, so the map
1040
+ // area is taller than the spec that reserves it.
1041
+ expect(withLegendNull.categoricalLegend).toBeNull();
1042
+ expect(withLegendNull.area.height).toBeGreaterThan(withoutLegendNull.area.height);
1043
+ });
1044
+
1045
+ it("legend position 'top-left' overlays the point legend inside the map area", () => {
1046
+ const spec = {
1047
+ type: 'map',
1048
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1049
+ data: [] as Array<Record<string, unknown>>,
1050
+ encoding: {
1051
+ key: { field: 'id', type: 'nominal' },
1052
+ color: { field: 'id', type: 'nominal', legend: null },
1053
+ },
1054
+ points: {
1055
+ data: [
1056
+ { lat: 34, lon: -118, name: 'LA', v: 10 },
1057
+ { lat: 40.7, lon: -74, name: 'NYC', v: 90 },
1058
+ ],
1059
+ longitude: { field: 'lon', type: 'quantitative' },
1060
+ latitude: { field: 'lat', type: 'quantitative' },
1061
+ color: { field: 'v', type: 'quantitative' },
1062
+ key: { field: 'name', type: 'nominal' },
1063
+ },
1064
+ theme: { spacing: { padding: 0 } },
1065
+ watermark: false,
1066
+ };
1067
+ const overlay = compileMap(
1068
+ { ...spec, legend: { show: true, position: 'top-left' } },
1069
+ DEFAULT_OPTIONS,
1070
+ );
1071
+ const bottom = compileMap({ ...spec, legend: { show: true } }, DEFAULT_OPTIONS);
1072
+
1073
+ // Overlay reserves no figure height: map area fills the full frame.
1074
+ expect(overlay.area.height).toBeGreaterThan(bottom.area.height);
1075
+ expect(overlay.area.height).toBe(DEFAULT_OPTIONS.height);
1076
+
1077
+ // Legend floats inside the map area's top-left corner, inset from the
1078
+ // edges, and is marked as an overlay for the renderer's backdrop.
1079
+ const legend = overlay.pointContinuousLegend!;
1080
+ expect(legend.position).toBe('top-left');
1081
+ expect(legend.bounds.x).toBeGreaterThan(overlay.area.x);
1082
+ expect(legend.bounds.y).toBeGreaterThan(overlay.area.y);
1083
+ expect(legend.bounds.y + legend.bounds.height).toBeLessThan(
1084
+ overlay.area.y + overlay.area.height,
1085
+ );
1086
+
1087
+ // Default (no position) keeps the bottom-row placement.
1088
+ expect(bottom.pointContinuousLegend!.position).toBe('bottom');
1089
+ expect(bottom.pointContinuousLegend!.bounds.y).toBeGreaterThan(
1090
+ overlay.pointContinuousLegend!.bounds.y,
1091
+ );
1092
+ });
1093
+
1094
+ it("legend position 'top-left' overlays a categorical point legend too", () => {
1095
+ const layout = compileMap(
1096
+ {
1097
+ type: 'map',
1098
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1099
+ data: [],
1100
+ encoding: {
1101
+ key: { field: 'id', type: 'nominal' },
1102
+ color: { field: 'id', type: 'nominal', legend: null },
1103
+ },
1104
+ points: {
1105
+ data: [
1106
+ { lat: 34, lon: -118, name: 'LA', cat: 'x' },
1107
+ { lat: 40.7, lon: -74, name: 'NYC', cat: 'y' },
1108
+ ],
1109
+ longitude: { field: 'lon', type: 'quantitative' },
1110
+ latitude: { field: 'lat', type: 'quantitative' },
1111
+ color: { field: 'cat', type: 'nominal' },
1112
+ key: { field: 'name', type: 'nominal' },
1113
+ },
1114
+ legend: { show: true, position: 'top-left' },
1115
+ theme: { spacing: { padding: 0 } },
1116
+ watermark: false,
1117
+ },
1118
+ DEFAULT_OPTIONS,
1119
+ );
1120
+
1121
+ expect(layout.area.height).toBe(DEFAULT_OPTIONS.height);
1122
+ const legend = layout.pointCategoricalLegend!;
1123
+ expect(legend.position).toBe('top-left');
1124
+ expect(legend.bounds.x).toBeGreaterThan(layout.area.x);
1125
+ expect(legend.bounds.y).toBeGreaterThan(layout.area.y);
1126
+ });
1003
1127
  });
1004
1128
  });
@@ -47,6 +47,13 @@ import { joinDataToFeatures } from './join';
47
47
  import { createProjection } from './projections';
48
48
  import type { NormalizedMapSpec } from './types';
49
49
 
50
+ /**
51
+ * Inset from the map area's edges for overlay ('top-left') point legends.
52
+ * The renderer's backdrop extends 10px left of the legend x, so the visible
53
+ * gap to the frame edge is OVERLAY_LEGEND_INSET - 10.
54
+ */
55
+ const OVERLAY_LEGEND_INSET = 18;
56
+
50
57
  function validateGeoFeatures(geo: NormalizedMapSpec['geo']): Topology {
51
58
  if (!geo.features) {
52
59
  throw new Error(
@@ -165,14 +172,21 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
165
172
  // 8. Legend height reserve
166
173
  const showLegend = mapSpec.legend?.show !== false;
167
174
  const colorEncoding = mapSpec.encoding.color;
175
+ // Channel-level `legend: null` suppresses the choropleth legend (the VL
176
+ // idiom) — without this, basemap-only maps (empty data + points layer)
177
+ // reserved a phantom swatch row that letterboxed the map.
178
+ const showChoroplethLegend = showLegend && colorEncoding?.legend !== null;
168
179
  const isQuantitative = colorEncoding?.type === 'quantitative';
169
180
  const legendPosition = mapSpec.legend?.position === 'bottom' ? 'bottom' : 'top';
181
+ // 'top-left' floats the point legend inside the map area (own backdrop, no
182
+ // height reserve) so the geography keeps the full frame.
183
+ const pointLegendOverlay = mapSpec.legend?.position === 'top-left';
170
184
 
171
185
  // Compute legend block height. For quantitative maps, use the continuous
172
186
  // legend infrastructure; for categorical, estimate a single swatch row.
173
187
  let legendBlockHeight = 0;
174
188
  let continuousContent: ReturnType<typeof computeContinuousLegendContentForChannel> = null;
175
- if (showLegend && colorEncoding && isQuantitative) {
189
+ if (showChoroplethLegend && colorEncoding && isQuantitative) {
176
190
  const colorValues: number[] = [];
177
191
  for (const row of mapSpec.data) {
178
192
  const v = row[colorEncoding.field];
@@ -197,13 +211,14 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
197
211
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
198
212
  legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
199
213
  }
200
- } else if (showLegend && colorEncoding) {
214
+ } else if (showChoroplethLegend && colorEncoding) {
201
215
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
202
216
  legendBlockHeight = Math.max(10, labelHeight) + 6;
203
217
  }
204
218
 
205
- // Reserve height for point color legend if applicable
206
- if (showLegend && mapSpec.points?.color) {
219
+ // Reserve height for point color legend if applicable (overlay legends
220
+ // float inside the map area and reserve nothing)
221
+ if (showLegend && !pointLegendOverlay && mapSpec.points?.color) {
207
222
  if (mapSpec.points.color.type === 'quantitative') {
208
223
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
209
224
  legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
@@ -221,8 +236,21 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
221
236
  }
222
237
 
223
238
  // 9. Projection + path generator
239
+ // When points are present, inset the projection by the max point radius so
240
+ // circles at the geographic edges don't get clipped by the viewBox.
224
241
  const projectionType = mapSpec.geo.projection;
225
- const projection = createProjection(projectionType, fullArea.width, mapAreaHeight, geoCollection);
242
+ let pointInset = 0;
243
+ if (mapSpec.points) {
244
+ const sizeRange = mapSpec.points.size?.scale?.range as readonly [number, number] | undefined;
245
+ pointInset = sizeRange ? sizeRange[1] : SIZE_SCALE_DEFAULTS.mapPoint.range[1];
246
+ }
247
+ const projection = createProjection(
248
+ projectionType,
249
+ fullArea.width,
250
+ mapAreaHeight,
251
+ geoCollection,
252
+ pointInset,
253
+ );
226
254
  const pathGen = geoPath(projection);
227
255
 
228
256
  // 10. Check winding order (skip for identity: geoArea uses spherical math
@@ -307,7 +335,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
307
335
 
308
336
  // Build the continuous legend. Maps place it directly (not via placeLegend,
309
337
  // which does cartesian-specific positioning above the chart area).
310
- if (showLegend && continuousContent) {
338
+ if (showChoroplethLegend && continuousContent) {
311
339
  const labelStyle: TextStyle = {
312
340
  fontFamily: theme.fonts.family,
313
341
  fontSize: theme.fonts.sizes.small,
@@ -361,7 +389,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
361
389
  mapAreaHeight,
362
390
  legendReserveGap,
363
391
  legendPosition,
364
- showLegend,
392
+ showLegend: showChoroplethLegend,
365
393
  droppedFeatures,
366
394
  });
367
395
  featureMarks = result.marks;
@@ -548,28 +576,34 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
548
576
  shape: 'circle' as const,
549
577
  }));
550
578
 
551
- // Position below the map. Only offset by choropleth legend height when
552
- // the choropleth legend is also at the bottom (not 'top').
579
+ // Default: position below the map, offset by choropleth legend height
580
+ // when that legend is also at the bottom (not 'top'). Overlay
581
+ // ('top-left'): float inside the map area's top-left corner — the
582
+ // renderer's backdrop keeps it readable over geography, and the inset
583
+ // clears the frame edge (plus the backdrop's own 10px x-padding).
553
584
  const bottomChoroplethHeight =
554
585
  legendPosition === 'bottom'
555
586
  ? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
556
587
  : 0;
557
588
  const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
558
- const legendX = fullArea.x;
559
- const legendY =
560
- fullArea.y +
561
- mapAreaHeight +
562
- legendReserveGap +
563
- bottomChoroplethHeight +
564
- choroplethLegendGap;
565
- const legendWidth = fullArea.width;
589
+ const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
590
+ const legendY = pointLegendOverlay
591
+ ? fullArea.y + OVERLAY_LEGEND_INSET
592
+ : fullArea.y +
593
+ mapAreaHeight +
594
+ legendReserveGap +
595
+ bottomChoroplethHeight +
596
+ choroplethLegendGap;
597
+ const legendWidth = pointLegendOverlay
598
+ ? fullArea.width - OVERLAY_LEGEND_INSET * 2
599
+ : fullArea.width;
566
600
  const swatchSize = 10;
567
601
  const swatchGap = 6;
568
602
  const entryGap = 16;
569
603
 
570
604
  pointCategoricalLegend = {
571
605
  type: 'categorical',
572
- position: 'bottom',
606
+ position: pointLegendOverlay ? 'top-left' : 'bottom',
573
607
  bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
574
608
  labelStyle,
575
609
  entries,
@@ -614,9 +648,10 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
614
648
  ? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
615
649
  : 0;
616
650
  const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
617
- const legendX = fullArea.x;
618
- const legendY =
619
- fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
651
+ const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
652
+ const legendY = pointLegendOverlay
653
+ ? fullArea.y + OVERLAY_LEGEND_INSET
654
+ : fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
620
655
  const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
621
656
  const ptLegendHeight =
622
657
  ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
@@ -635,7 +670,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
635
670
  pointContinuousLegend = {
636
671
  type: 'continuous' as const,
637
672
  mode: ptContinuousContent.mode,
638
- position: 'bottom',
673
+ position: pointLegendOverlay ? ('top-left' as const) : ('bottom' as const),
639
674
  bounds,
640
675
  labelStyle,
641
676
  bar,
@@ -12,8 +12,18 @@ export function createProjection(
12
12
  width: number,
13
13
  height: number,
14
14
  geojson: GeoPermissibleObjects,
15
+ inset = 0,
15
16
  ): GeoProjection | GeoIdentityTransform {
16
17
  if (type === 'identity') {
18
+ if (inset > 0) {
19
+ return geoIdentity().fitExtent(
20
+ [
21
+ [inset, inset],
22
+ [width - inset, height - inset],
23
+ ],
24
+ geojson,
25
+ );
26
+ }
17
27
  return geoIdentity().fitSize([width, height], geojson);
18
28
  }
19
29
 
@@ -21,7 +31,17 @@ export function createProjection(
21
31
  type === 'albersUsa' ? geoAlbersUsa() : type === 'equalEarth' ? geoEqualEarth() : geoMercator();
22
32
 
23
33
  const fitGeometry = type === 'mercator' ? excludePolarFeatures(geojson) : geojson;
24
- projection.fitSize([width, height], fitGeometry);
34
+ if (inset > 0) {
35
+ projection.fitExtent(
36
+ [
37
+ [inset, inset],
38
+ [width - inset, height - inset],
39
+ ],
40
+ fitGeometry,
41
+ );
42
+ } else {
43
+ projection.fitSize([width, height], fitGeometry);
44
+ }
25
45
  return projection;
26
46
  }
27
47
 
@@ -388,9 +388,10 @@ export function computeLegendContent(
388
388
  lineHeight: 1.3,
389
389
  };
390
390
 
391
- // Resolve position: spec-level override wins, then responsive strategy
392
- const resolvedPosition =
393
- spec.legend?.position ?? (strategy.legendPosition === 'right' ? 'right' : 'top');
391
+ // Resolve position: spec-level override wins, then responsive strategy.
392
+ // 'top-left' is a map-only overlay position; charts fold it into 'top'.
393
+ const specPosition = spec.legend?.position === 'top-left' ? 'top' : spec.legend?.position;
394
+ const resolvedPosition = specPosition ?? (strategy.legendPosition === 'right' ? 'right' : 'top');
394
395
 
395
396
  // No entries = empty legend with no space
396
397
  if (entries.length === 0) {