@opendata-ai/openchart-engine 8.0.0-rc.6 → 8.0.0-rc.7
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 +122 -34
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/annotation-behavior.test.ts +131 -0
- package/src/annotations/geometry.ts +7 -1
- package/src/annotations/resolve-text.ts +6 -0
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +84 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +162 -0
- package/src/compiler/validate.ts +155 -27
- package/src/geo/compile-map.ts +12 -5
package/src/compiler/validate.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MARK_TYPES,
|
|
17
17
|
type MarkType,
|
|
18
18
|
resolveSchemeName,
|
|
19
|
+
SEQUENTIAL_PALETTES,
|
|
19
20
|
SUPPORTED_SCHEME_NAMES,
|
|
20
21
|
type VizSpec,
|
|
21
22
|
} from '@opendata-ai/openchart-core';
|
|
@@ -107,6 +108,94 @@ function didYouMean(field: string, columns: string[]): string {
|
|
|
107
108
|
return match ? ` Did you mean "${match}"?` : '';
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Push an INVALID_VALUE error when a channel carries an unrecognized
|
|
113
|
+
* scale.scheme name. Chart specs are the only family that consumes schemes
|
|
114
|
+
* this way: known names (including VL aliases) resolve via the core palette
|
|
115
|
+
* registry and are expanded to scale.range by the pre-validation sugar pass,
|
|
116
|
+
* so `resolveSchemeName` failing means the name is unknown.
|
|
117
|
+
*/
|
|
118
|
+
function checkSchemeName(
|
|
119
|
+
channelObj: Record<string, unknown>,
|
|
120
|
+
channelPath: string,
|
|
121
|
+
errors: ValidationError[],
|
|
122
|
+
): void {
|
|
123
|
+
const scale = channelObj.scale as Record<string, unknown> | undefined;
|
|
124
|
+
if (scale && typeof scale.scheme === 'string' && !resolveSchemeName(scale.scheme)) {
|
|
125
|
+
errors.push({
|
|
126
|
+
message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported scheme name`,
|
|
127
|
+
path: `${channelPath}.scale.scheme`,
|
|
128
|
+
code: 'INVALID_VALUE',
|
|
129
|
+
suggestion: `Use one of the supported scheme names: ${[...SUPPORTED_SCHEME_NAMES].join(', ')}. Or provide explicit colors via scale.range.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Push an INVALID_VALUE error when any channel of an encoding block carries a
|
|
136
|
+
* scale.scheme on a spec family whose compile path never reads it. Even a
|
|
137
|
+
* known scheme name is dead config there — silently accepting it would leave
|
|
138
|
+
* the author believing they chose the colors.
|
|
139
|
+
*/
|
|
140
|
+
function checkSchemeUnused(
|
|
141
|
+
encoding: Record<string, unknown>,
|
|
142
|
+
errors: ValidationError[],
|
|
143
|
+
familyNote: string,
|
|
144
|
+
suggestion: string,
|
|
145
|
+
): void {
|
|
146
|
+
for (const [channel, ch] of Object.entries(encoding)) {
|
|
147
|
+
if (!ch || typeof ch !== 'object' || Array.isArray(ch)) continue;
|
|
148
|
+
const scale = (ch as Record<string, unknown>).scale as Record<string, unknown> | undefined;
|
|
149
|
+
if (scale && typeof scale.scheme === 'string') {
|
|
150
|
+
errors.push({
|
|
151
|
+
message: `Spec error: encoding.${channel}.scale.scheme has no effect — ${familyNote}`,
|
|
152
|
+
path: `encoding.${channel}.scale.scheme`,
|
|
153
|
+
code: 'INVALID_VALUE',
|
|
154
|
+
suggestion,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Palette names the map compile path actually supports (sequential only). */
|
|
161
|
+
const MAP_PALETTE_NAMES = Object.keys(SEQUENTIAL_PALETTES).join(', ');
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Map color channels resolve scale.scheme against SEQUENTIAL_PALETTES with a
|
|
165
|
+
* silent fallback to blue — a typo'd scheme would render the default palette
|
|
166
|
+
* with no signal. Reject names outside the map's supported set here instead.
|
|
167
|
+
* Categorical channels never read schemes at all (colors come from
|
|
168
|
+
* scale.domain/scale.range), so any scheme there is dead config.
|
|
169
|
+
*/
|
|
170
|
+
function checkMapSchemeName(
|
|
171
|
+
channelObj: Record<string, unknown>,
|
|
172
|
+
channelPath: string,
|
|
173
|
+
errors: ValidationError[],
|
|
174
|
+
): void {
|
|
175
|
+
const scale = channelObj.scale as Record<string, unknown> | undefined;
|
|
176
|
+
if (!scale || typeof scale.scheme !== 'string') return;
|
|
177
|
+
|
|
178
|
+
if (channelObj.type === 'nominal' || channelObj.type === 'ordinal') {
|
|
179
|
+
errors.push({
|
|
180
|
+
message: `Spec error: ${channelPath}.scale.scheme has no effect — categorical map colors come from scale.range and scale.domain`,
|
|
181
|
+
path: `${channelPath}.scale.scheme`,
|
|
182
|
+
code: 'INVALID_VALUE',
|
|
183
|
+
suggestion:
|
|
184
|
+
'Remove scale.scheme. To customize categorical map colors, provide explicit colors via scale.range (with scale.domain for category order).',
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!(scale.scheme in SEQUENTIAL_PALETTES)) {
|
|
190
|
+
errors.push({
|
|
191
|
+
message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported map palette`,
|
|
192
|
+
path: `${channelPath}.scale.scheme`,
|
|
193
|
+
code: 'INVALID_VALUE',
|
|
194
|
+
suggestion: `Maps support the sequential palettes: ${MAP_PALETTE_NAMES}. The default is "blue".`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
110
199
|
// ---------------------------------------------------------------------------
|
|
111
200
|
// Range mark validation
|
|
112
201
|
// ---------------------------------------------------------------------------
|
|
@@ -613,19 +702,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
613
702
|
// Check scale.scheme names. Known names (including VL aliases) are
|
|
614
703
|
// resolved to palette ranges by the pre-validation sugar expansion, so a
|
|
615
704
|
// scheme surviving to this point on the compile path is an unknown name.
|
|
616
|
-
|
|
617
|
-
if (
|
|
618
|
-
channelScale &&
|
|
619
|
-
typeof channelScale.scheme === 'string' &&
|
|
620
|
-
!resolveSchemeName(channelScale.scheme)
|
|
621
|
-
) {
|
|
622
|
-
errors.push({
|
|
623
|
-
message: `Spec error: encoding.${channel}.scale.scheme "${channelScale.scheme}" is not a supported scheme name`,
|
|
624
|
-
path: `encoding.${channel}.scale.scheme`,
|
|
625
|
-
code: 'INVALID_VALUE',
|
|
626
|
-
suggestion: `Use one of the supported scheme names: ${[...SUPPORTED_SCHEME_NAMES].join(', ')}. Or provide explicit colors via scale.range.`,
|
|
627
|
-
});
|
|
628
|
-
}
|
|
705
|
+
checkSchemeName(channelObj, `encoding.${channel}`, errors);
|
|
629
706
|
|
|
630
707
|
// Check field type is allowed for this channel
|
|
631
708
|
if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
|
|
@@ -1022,7 +1099,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1022
1099
|
message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
|
|
1023
1100
|
path: `edges[${i}].source`,
|
|
1024
1101
|
code: 'DATA_FIELD_MISSING',
|
|
1025
|
-
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
1102
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.source, [...nodeIds])}`,
|
|
1026
1103
|
});
|
|
1027
1104
|
}
|
|
1028
1105
|
|
|
@@ -1038,7 +1115,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1038
1115
|
message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
|
|
1039
1116
|
path: `edges[${i}].target`,
|
|
1040
1117
|
code: 'DATA_FIELD_MISSING',
|
|
1041
|
-
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
1118
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}.${didYouMean(edge.target, [...nodeIds])}`,
|
|
1042
1119
|
});
|
|
1043
1120
|
}
|
|
1044
1121
|
}
|
|
@@ -1059,7 +1136,7 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1059
1136
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(', ')}`,
|
|
1060
1137
|
path: `encoding.${channel}.field`,
|
|
1061
1138
|
code: 'DATA_FIELD_MISSING',
|
|
1062
|
-
suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}`,
|
|
1139
|
+
suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}.${didYouMean(ch.field, [...nodeFields])}`,
|
|
1063
1140
|
});
|
|
1064
1141
|
}
|
|
1065
1142
|
}
|
|
@@ -1072,10 +1149,18 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
1072
1149
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(', ')}`,
|
|
1073
1150
|
path: `encoding.${channel}.field`,
|
|
1074
1151
|
code: 'DATA_FIELD_MISSING',
|
|
1075
|
-
suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}`,
|
|
1152
|
+
suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}.${didYouMean(ch.field, [...edgeFields])}`,
|
|
1076
1153
|
});
|
|
1077
1154
|
}
|
|
1078
1155
|
}
|
|
1156
|
+
|
|
1157
|
+
// Graph never reads scale.scheme: colors come from scale.range/scale.domain
|
|
1158
|
+
checkSchemeUnused(
|
|
1159
|
+
encoding,
|
|
1160
|
+
errors,
|
|
1161
|
+
'graph colors come from scale.range and scale.domain',
|
|
1162
|
+
'Remove scale.scheme. To customize graph colors, provide explicit colors via scale.range.',
|
|
1163
|
+
);
|
|
1079
1164
|
}
|
|
1080
1165
|
|
|
1081
1166
|
// Validate layout type if specified
|
|
@@ -1147,7 +1232,8 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
|
|
|
1147
1232
|
|
|
1148
1233
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1149
1234
|
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1150
|
-
const
|
|
1235
|
+
const columnList = [...dataColumns];
|
|
1236
|
+
const availableColumns = columnList.join(', ');
|
|
1151
1237
|
|
|
1152
1238
|
// Required channels
|
|
1153
1239
|
for (const channel of ['source', 'target', 'value'] as const) {
|
|
@@ -1157,7 +1243,7 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
|
|
|
1157
1243
|
message: `Spec error: sankey encoding requires "${channel}" channel`,
|
|
1158
1244
|
path: `encoding.${channel}`,
|
|
1159
1245
|
code: 'MISSING_FIELD',
|
|
1160
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[
|
|
1246
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1161
1247
|
});
|
|
1162
1248
|
continue;
|
|
1163
1249
|
}
|
|
@@ -1177,11 +1263,19 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
|
|
|
1177
1263
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1178
1264
|
path: `encoding.${channel}.field`,
|
|
1179
1265
|
code: 'DATA_FIELD_MISSING',
|
|
1180
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1266
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
|
|
1181
1267
|
});
|
|
1182
1268
|
}
|
|
1183
1269
|
}
|
|
1184
1270
|
|
|
1271
|
+
// Sankey never reads scale.scheme: node colors cycle theme.colors.categorical
|
|
1272
|
+
checkSchemeUnused(
|
|
1273
|
+
encoding,
|
|
1274
|
+
errors,
|
|
1275
|
+
'sankey node colors cycle the theme categorical palette',
|
|
1276
|
+
'Remove scale.scheme. To customize sankey colors, set theme.colors.categorical.',
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1185
1279
|
// Validate darkMode if provided
|
|
1186
1280
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1187
1281
|
errors.push({
|
|
@@ -1260,7 +1354,8 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1260
1354
|
|
|
1261
1355
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1262
1356
|
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1263
|
-
const
|
|
1357
|
+
const columnList = [...dataColumns];
|
|
1358
|
+
const availableColumns = columnList.join(', ');
|
|
1264
1359
|
|
|
1265
1360
|
// Required channels
|
|
1266
1361
|
for (const channel of ['state', 'value'] as const) {
|
|
@@ -1270,7 +1365,7 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1270
1365
|
message: `Spec error: tilemap encoding requires "${channel}" channel`,
|
|
1271
1366
|
path: `encoding.${channel}`,
|
|
1272
1367
|
code: 'MISSING_FIELD',
|
|
1273
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[
|
|
1368
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1274
1369
|
});
|
|
1275
1370
|
continue;
|
|
1276
1371
|
}
|
|
@@ -1290,12 +1385,23 @@ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1290
1385
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1291
1386
|
path: `encoding.${channel}.field`,
|
|
1292
1387
|
code: 'DATA_FIELD_MISSING',
|
|
1293
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1388
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
|
|
1294
1389
|
});
|
|
1295
1390
|
}
|
|
1296
1391
|
}
|
|
1297
1392
|
}
|
|
1298
1393
|
|
|
1394
|
+
// Tilemap never reads scale.scheme: fills come from the top-level palette
|
|
1395
|
+
// prop (encoding may be present in both record-map and array-data modes)
|
|
1396
|
+
if (spec.encoding && typeof spec.encoding === 'object') {
|
|
1397
|
+
checkSchemeUnused(
|
|
1398
|
+
spec.encoding as Record<string, unknown>,
|
|
1399
|
+
errors,
|
|
1400
|
+
'tilemap fills come from the top-level palette property',
|
|
1401
|
+
`Remove scale.scheme and set the top-level palette property instead, e.g. palette: "green". Supported: ${MAP_PALETTE_NAMES}.`,
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1299
1405
|
// Validate darkMode if provided
|
|
1300
1406
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1301
1407
|
errors.push({
|
|
@@ -1346,6 +1452,19 @@ function validateMapSpec(spec: Record<string, unknown>, errors: ValidationError[
|
|
|
1346
1452
|
return;
|
|
1347
1453
|
}
|
|
1348
1454
|
|
|
1455
|
+
// Map color channels resolve scale.scheme against the sequential palettes
|
|
1456
|
+
// with a silent blue fallback; reject unsupported names here instead.
|
|
1457
|
+
const mapEncoding = spec.encoding as Record<string, unknown>;
|
|
1458
|
+
if (mapEncoding.color && typeof mapEncoding.color === 'object') {
|
|
1459
|
+
checkMapSchemeName(mapEncoding.color as Record<string, unknown>, 'encoding.color', errors);
|
|
1460
|
+
}
|
|
1461
|
+
if (spec.points && typeof spec.points === 'object') {
|
|
1462
|
+
const pointsLayer = spec.points as Record<string, unknown>;
|
|
1463
|
+
if (pointsLayer.color && typeof pointsLayer.color === 'object') {
|
|
1464
|
+
checkMapSchemeName(pointsLayer.color as Record<string, unknown>, 'points.color', errors);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1349
1468
|
// Validate geo.focus if present
|
|
1350
1469
|
if ('focus' in geo && geo.focus !== undefined && geo.focus !== null) {
|
|
1351
1470
|
const focus = geo.focus;
|
|
@@ -1593,7 +1712,8 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1593
1712
|
|
|
1594
1713
|
const encoding = spec.encoding as Record<string, unknown>;
|
|
1595
1714
|
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
1596
|
-
const
|
|
1715
|
+
const columnList = [...dataColumns];
|
|
1716
|
+
const availableColumns = columnList.join(', ');
|
|
1597
1717
|
|
|
1598
1718
|
for (const channel of ['label', 'value'] as const) {
|
|
1599
1719
|
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
@@ -1602,7 +1722,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1602
1722
|
message: `Spec error: barlist encoding requires "${channel}" channel`,
|
|
1603
1723
|
path: `encoding.${channel}`,
|
|
1604
1724
|
code: 'MISSING_FIELD',
|
|
1605
|
-
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[
|
|
1725
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
1606
1726
|
});
|
|
1607
1727
|
continue;
|
|
1608
1728
|
}
|
|
@@ -1622,7 +1742,7 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1622
1742
|
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1623
1743
|
path: `encoding.${channel}.field`,
|
|
1624
1744
|
code: 'DATA_FIELD_MISSING',
|
|
1625
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1745
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field as string, columnList)}`,
|
|
1626
1746
|
});
|
|
1627
1747
|
}
|
|
1628
1748
|
}
|
|
@@ -1637,11 +1757,19 @@ function validateBarListSpec(spec: Record<string, unknown>, errors: ValidationEr
|
|
|
1637
1757
|
message: `Spec error: encoding.${channel}.field "${field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1638
1758
|
path: `encoding.${channel}.field`,
|
|
1639
1759
|
code: 'DATA_FIELD_MISSING',
|
|
1640
|
-
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
1760
|
+
suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(field, columnList)}`,
|
|
1641
1761
|
});
|
|
1642
1762
|
}
|
|
1643
1763
|
}
|
|
1644
1764
|
|
|
1765
|
+
// Barlist never reads scale.scheme: bars cycle the built-in barlist palette
|
|
1766
|
+
checkSchemeUnused(
|
|
1767
|
+
encoding,
|
|
1768
|
+
errors,
|
|
1769
|
+
'bar list colors cycle the built-in barlist palette',
|
|
1770
|
+
'Remove scale.scheme. Bar list colors are not configurable via encoding; they cycle the built-in palette.',
|
|
1771
|
+
);
|
|
1772
|
+
|
|
1645
1773
|
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
1646
1774
|
errors.push({
|
|
1647
1775
|
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|
package/src/geo/compile-map.ts
CHANGED
|
@@ -1217,13 +1217,20 @@ function resolveFocus(
|
|
|
1217
1217
|
let maxX = -Infinity;
|
|
1218
1218
|
let maxY = -Infinity;
|
|
1219
1219
|
for (const p of matched) {
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1220
|
+
// Use center points only, not cx ± r: the camera counter-scales point
|
|
1221
|
+
// radii to constant screen size, so the base radius doesn't occupy
|
|
1222
|
+
// projected space after the transform. Including it inflates the
|
|
1223
|
+
// bounding box and produces a much shallower zoom than expected.
|
|
1224
|
+
minX = Math.min(minX, p.cx);
|
|
1225
|
+
minY = Math.min(minY, p.cy);
|
|
1226
|
+
maxX = Math.max(maxX, p.cx);
|
|
1227
|
+
maxY = Math.max(maxY, p.cy);
|
|
1224
1228
|
}
|
|
1229
|
+
// Guard against degenerate bounding box (single point or coincident points)
|
|
1230
|
+
const w = Math.max(maxX - minX, 1);
|
|
1231
|
+
const h = Math.max(maxY - minY, 1);
|
|
1225
1232
|
return {
|
|
1226
|
-
target: { x: minX, y: minY, width:
|
|
1233
|
+
target: { x: minX, y: minY, width: w, height: h, padding },
|
|
1227
1234
|
// No feature ids: focus-dim is a feature concept, so a points focus
|
|
1228
1235
|
// dims no features (there are no "other" features to mute).
|
|
1229
1236
|
ids: [],
|