@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/dist/index.js +91 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +62 -0
- package/src/compile/layer.ts +4 -0
- package/src/compile.ts +54 -1
- package/src/geo/__tests__/compile-map.test.ts +124 -0
- package/src/geo/compile-map.ts +57 -22
- package/src/geo/projections.ts +21 -1
- package/src/legend/compute.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "8.0.0-rc.
|
|
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.
|
|
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
|
`;
|
package/src/compile/layer.ts
CHANGED
|
@@ -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
|
});
|
package/src/geo/compile-map.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
552
|
-
//
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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 +
|
|
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,
|
package/src/geo/projections.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/legend/compute.ts
CHANGED
|
@@ -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
|
-
|
|
393
|
-
|
|
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) {
|