@orbat-mapper/tactical-draw 0.2.0-alpha.30 → 0.2.0-alpha.32
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.mts +168 -3
- package/dist/index.mjs +728 -142
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, renderControlMeasure, resolveAmplifierPlacement, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
|
|
1
|
+
import { applyBoxTransformOptions, canonicalTextAmplifierKey, cloneControlMeasure, controlMeasureIdFromFeature, freezeOrientationOptions, getControlMeasureMetadata, getDefaultOptions, renderControlMeasure, resolveAmplifierPlacement, resolveStyleHints, roundToFixed } from "@orbat-mapper/control-measures";
|
|
2
2
|
//#region src/base-map-adapter.ts
|
|
3
3
|
/**
|
|
4
4
|
* Abstract base class shared by all `MapAdapter` implementations.
|
|
@@ -218,26 +218,17 @@ var BaseMapAdapter = class {
|
|
|
218
218
|
return this.featureCache.get(layerId) ?? [];
|
|
219
219
|
}
|
|
220
220
|
};
|
|
221
|
-
function
|
|
222
|
-
if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent)
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
function pickPointerType(originalEvent) {
|
|
222
|
+
if (typeof PointerEvent !== "undefined" && originalEvent instanceof PointerEvent) {
|
|
223
|
+
if (originalEvent.pointerType === "touch") return "touch";
|
|
224
|
+
return originalEvent.pointerType === "pen" ? "pen" : "mouse";
|
|
225
|
+
}
|
|
226
|
+
if (typeof TouchEvent !== "undefined" && originalEvent instanceof TouchEvent) return "touch";
|
|
227
|
+
if (originalEvent && typeof originalEvent === "object" && "originalEvent" in originalEvent) return pickPointerType(originalEvent.originalEvent);
|
|
228
|
+
return "mouse";
|
|
225
229
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
* `MapAdapter.on(...)` pipeline. Unlike {@link PickEvent}, that pipeline forwards
|
|
229
|
-
* the *engine wrapper* event (OpenLayers `MapBrowserEvent`, MapLibre
|
|
230
|
-
* `MapMouseEvent`, Leaflet `LeafletMouseEvent`), each of which carries the raw
|
|
231
|
-
* DOM event on a `.originalEvent` property. {@link pickHitTolerance} only
|
|
232
|
-
* recognizes a real `PointerEvent` / `TouchEvent`, so unwrap one level when the
|
|
233
|
-
* wrapper itself is not recognized as touch. Falls back to the mouse tolerance
|
|
234
|
-
* when the input type cannot be determined.
|
|
235
|
-
*/
|
|
236
|
-
function mapEventHitTolerance(originalEvent) {
|
|
237
|
-
const direct = pickHitTolerance(originalEvent);
|
|
238
|
-
if (direct === 12) return direct;
|
|
239
|
-
if (originalEvent && typeof originalEvent === "object" && "originalEvent" in originalEvent) return pickHitTolerance(originalEvent.originalEvent);
|
|
240
|
-
return direct;
|
|
230
|
+
function pickHitTolerance(originalEvent) {
|
|
231
|
+
return pickPointerType(originalEvent) === "touch" ? 12 : 4;
|
|
241
232
|
}
|
|
242
233
|
function tryControlMeasureIdFromFeature(feature) {
|
|
243
234
|
const graphicId = feature.properties?.graphicId;
|
|
@@ -268,6 +259,392 @@ function bindAbortable(signal, attach) {
|
|
|
268
259
|
return dispose;
|
|
269
260
|
}
|
|
270
261
|
//#endregion
|
|
262
|
+
//#region src/pick-candidates.ts
|
|
263
|
+
/**
|
|
264
|
+
* The `visualOrder` for the feature at `index` of a render-ordered
|
|
265
|
+
* (bottom-first) list of `total` features: reversing the index makes the
|
|
266
|
+
* topmost-rendered feature the lowest, and therefore winning, order.
|
|
267
|
+
*/
|
|
268
|
+
function renderOrderToVisualOrder(index, total) {
|
|
269
|
+
return total - 1 - index;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Preserve native visual ordering/hit evidence while keeping cached source
|
|
273
|
+
* geometry authoritative. Native polygon geometry may be tile-clipped and
|
|
274
|
+
* therefore contain artificial edges that are not part of the graphic.
|
|
275
|
+
*
|
|
276
|
+
* Normalizes to the shared `visualOrder` contract (lower = visually
|
|
277
|
+
* topmost): native hits already arrive topmost-first from the engines and
|
|
278
|
+
* keep that order at the front. Cached-only features (not among the native
|
|
279
|
+
* hits) are appended after them, in reverse cache order — cache arrays are
|
|
280
|
+
* render order (bottom-first), so reversing yields topmost-first — with
|
|
281
|
+
* `visualOrder` numbering continuing on from the native section.
|
|
282
|
+
*/
|
|
283
|
+
function mergeNativeAndCachedPickFeatures(nativeFeatures, cachedFeatures) {
|
|
284
|
+
const cachedById = /* @__PURE__ */ new Map();
|
|
285
|
+
for (const feature of cachedFeatures) if (feature.id != null) cachedById.set(feature.id, feature);
|
|
286
|
+
const merged = [];
|
|
287
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
288
|
+
for (const nativeFeature of nativeFeatures) {
|
|
289
|
+
if (nativeFeature.id != null && seenIds.has(nativeFeature.id)) continue;
|
|
290
|
+
const feature = nativeFeature.id == null ? nativeFeature : cachedById.get(nativeFeature.id) ?? nativeFeature;
|
|
291
|
+
merged.push({
|
|
292
|
+
feature,
|
|
293
|
+
visualOrder: merged.length,
|
|
294
|
+
nativeHit: true
|
|
295
|
+
});
|
|
296
|
+
if (nativeFeature.id != null) seenIds.add(nativeFeature.id);
|
|
297
|
+
}
|
|
298
|
+
for (let i = cachedFeatures.length - 1; i >= 0; i--) {
|
|
299
|
+
const feature = cachedFeatures[i];
|
|
300
|
+
if (feature.id != null && seenIds.has(feature.id)) continue;
|
|
301
|
+
merged.push({
|
|
302
|
+
feature,
|
|
303
|
+
visualOrder: merged.length,
|
|
304
|
+
nativeHit: false
|
|
305
|
+
});
|
|
306
|
+
if (feature.id != null) seenIds.add(feature.id);
|
|
307
|
+
}
|
|
308
|
+
return merged;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Evaluate every relevant rendered part in screen space, then return the
|
|
312
|
+
* primary candidate under the visible-intent ranking policy: direct visible
|
|
313
|
+
* marks (strokes, points, icons, labels) outrank visibly filled interiors,
|
|
314
|
+
* which outrank inferred outline-only reach; competing direct marks rank by
|
|
315
|
+
* screen-space distance; visual topmost order is only the final tie-break.
|
|
316
|
+
* See `rankPickCandidates` for the full ordered list — that ordering stays
|
|
317
|
+
* internal adapter-author plumbing; the public pick event exposes only this
|
|
318
|
+
* primary candidate.
|
|
319
|
+
*/
|
|
320
|
+
function evaluatePickCandidates(inputs, context) {
|
|
321
|
+
let best = null;
|
|
322
|
+
for (const input of inputs) {
|
|
323
|
+
const candidate = buildCandidate(input, context);
|
|
324
|
+
if (candidate && (best === null || compareCandidates(candidate, best) < 0)) best = candidate;
|
|
325
|
+
}
|
|
326
|
+
return best;
|
|
327
|
+
}
|
|
328
|
+
function buildCandidate(input, context) {
|
|
329
|
+
const presentation = input.presentation ?? resolvePickPresentation(input.feature, input.layerStyle);
|
|
330
|
+
const evaluated = evaluateInput(input, presentation, context);
|
|
331
|
+
if (!evaluated) return null;
|
|
332
|
+
return {
|
|
333
|
+
feature: input.feature,
|
|
334
|
+
graphicId: tryControlMeasureIdFromFeature(input.feature),
|
|
335
|
+
renderedPartId: typeof input.feature.id === "string" || typeof input.feature.id === "number" ? input.feature.id : null,
|
|
336
|
+
hit: evaluated.hit,
|
|
337
|
+
viewport: context.viewport,
|
|
338
|
+
presentation,
|
|
339
|
+
visualOrder: input.visualOrder,
|
|
340
|
+
...evaluated.iconFootprint ? { iconFootprint: evaluated.iconFootprint } : {},
|
|
341
|
+
...input.labelExtent ? { labelExtent: input.labelExtent } : {}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function compareCandidates(a, b) {
|
|
345
|
+
const rankA = selectionSurfaceRank(a);
|
|
346
|
+
const rankDifference = rankA - selectionSurfaceRank(b);
|
|
347
|
+
if (rankDifference !== 0) return rankDifference;
|
|
348
|
+
if (rankA === 0 && a.hit.distance !== null && b.hit.distance !== null) {
|
|
349
|
+
const distanceDifference = a.hit.distance - b.hit.distance;
|
|
350
|
+
if (distanceDifference !== 0) return distanceDifference;
|
|
351
|
+
}
|
|
352
|
+
return a.visualOrder - b.visualOrder;
|
|
353
|
+
}
|
|
354
|
+
function selectionSurfaceRank(candidate) {
|
|
355
|
+
if (candidate.hit.kind !== "polygon-interior") return 0;
|
|
356
|
+
return (candidate.presentation.fill?.opacity ?? 0) > 0 ? 1 : 2;
|
|
357
|
+
}
|
|
358
|
+
function resolvePickPresentation(feature, layerStyle) {
|
|
359
|
+
const featureStyle = asRecord(feature.properties?.style);
|
|
360
|
+
const strokeColor = stringValue(featureStyle?.strokeColor) ?? layerStyle?.strokeColor;
|
|
361
|
+
const strokeWidth = numberValue(featureStyle?.strokeWidth) ?? layerStyle?.strokeWidth;
|
|
362
|
+
const strokeDash = numberArray(featureStyle?.strokeDash) ?? layerStyle?.strokeDash;
|
|
363
|
+
const fillColor = stringValue(featureStyle?.fillColor) ?? layerStyle?.fillColor;
|
|
364
|
+
const fillPattern = stringValue(featureStyle?.fillPattern);
|
|
365
|
+
const pointFillColor = stringValue(featureStyle?.pointFillColor) ?? stringValue(featureStyle?.fillColor) ?? layerStyle?.pointFillColor ?? layerStyle?.fillColor;
|
|
366
|
+
const pointRadius = numberValue(featureStyle?.pointRadius) ?? layerStyle?.pointRadius;
|
|
367
|
+
const presentation = {};
|
|
368
|
+
if (strokeColor !== void 0 || strokeWidth !== void 0 || strokeDash !== void 0) presentation.stroke = {
|
|
369
|
+
...strokeColor !== void 0 ? { color: strokeColor } : {},
|
|
370
|
+
...strokeWidth !== void 0 ? { width: strokeWidth } : {},
|
|
371
|
+
...strokeDash !== void 0 ? { dash: strokeDash } : {}
|
|
372
|
+
};
|
|
373
|
+
if (fillColor !== void 0 || fillPattern !== void 0) presentation.fill = {
|
|
374
|
+
...fillColor !== void 0 ? { color: fillColor } : {},
|
|
375
|
+
...fillPattern !== void 0 ? { pattern: fillPattern } : {},
|
|
376
|
+
opacity: colorOpacity(fillColor)
|
|
377
|
+
};
|
|
378
|
+
if (feature.geometry.type === "Point" && (pointFillColor !== void 0 || pointRadius !== void 0)) presentation.point = {
|
|
379
|
+
...pointFillColor !== void 0 ? { fillColor: pointFillColor } : {},
|
|
380
|
+
...pointRadius !== void 0 ? { radius: pointRadius } : {}
|
|
381
|
+
};
|
|
382
|
+
if (layerStyle?.text !== void 0) presentation.text = { ...layerStyle.text };
|
|
383
|
+
return presentation;
|
|
384
|
+
}
|
|
385
|
+
function evaluateInput(input, presentation, context) {
|
|
386
|
+
if (input.labelExtent && pointInExtent(context.pixel, input.labelExtent)) return { hit: {
|
|
387
|
+
kind: "label",
|
|
388
|
+
distance: 0
|
|
389
|
+
} };
|
|
390
|
+
if (input.icon) return evaluateIcon(input.feature, input.icon, context);
|
|
391
|
+
const geometryHit = evaluateGeometry(input.feature, presentation, context);
|
|
392
|
+
if (geometryHit) return { hit: geometryHit };
|
|
393
|
+
if (input.feature.geometry.type === "Polygon" || input.feature.geometry.type === "MultiPolygon") return null;
|
|
394
|
+
return input.nativeHit ? { hit: {
|
|
395
|
+
kind: inferNativeHitKind(input.feature),
|
|
396
|
+
distance: null
|
|
397
|
+
} } : null;
|
|
398
|
+
}
|
|
399
|
+
function inferNativeHitKind(feature) {
|
|
400
|
+
if (typeof feature.properties?.iconId === "string") return "icon";
|
|
401
|
+
if (typeof feature.properties?.text === "string") return "label";
|
|
402
|
+
if (feature.geometry.type === "Point") return "point";
|
|
403
|
+
if (feature.geometry.type === "LineString" || feature.geometry.type === "MultiLineString") return "line";
|
|
404
|
+
return "native";
|
|
405
|
+
}
|
|
406
|
+
function evaluateGeometry(feature, presentation, context) {
|
|
407
|
+
const geometry = feature.geometry;
|
|
408
|
+
const { pixel, tolerance, toPixel } = context;
|
|
409
|
+
if (geometry.type === "Point") {
|
|
410
|
+
const projected = toPixel(geometry.coordinates);
|
|
411
|
+
if (!projected) return null;
|
|
412
|
+
const distance = pointDistance(pixel, projected);
|
|
413
|
+
return distance <= tolerance ? {
|
|
414
|
+
kind: "point",
|
|
415
|
+
distance
|
|
416
|
+
} : null;
|
|
417
|
+
}
|
|
418
|
+
if (geometry.type === "LineString") {
|
|
419
|
+
const distance = distanceToLine(pixel, geometry.coordinates, toPixel);
|
|
420
|
+
return distance !== null && distance <= tolerance ? {
|
|
421
|
+
kind: "line",
|
|
422
|
+
distance
|
|
423
|
+
} : null;
|
|
424
|
+
}
|
|
425
|
+
if (geometry.type === "MultiLineString") {
|
|
426
|
+
const distance = minimum(geometry.coordinates.map((line) => distanceToLine(pixel, line, toPixel)));
|
|
427
|
+
return distance !== null && distance <= tolerance ? {
|
|
428
|
+
kind: "line",
|
|
429
|
+
distance
|
|
430
|
+
} : null;
|
|
431
|
+
}
|
|
432
|
+
if (geometry.type === "Polygon") return evaluatePolygon(pixel, geometry.coordinates, presentation, context);
|
|
433
|
+
if (geometry.type === "MultiPolygon") return bestPolygonHit(geometry.coordinates.map((polygon) => evaluatePolygon(pixel, polygon, presentation, context)).filter((hit) => hit !== null));
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
function evaluatePolygon(pixel, rings, presentation, context) {
|
|
437
|
+
const { tolerance, toPixel, viewport } = context;
|
|
438
|
+
const projectedRings = rings.map((ring) => ring.map(toPixel));
|
|
439
|
+
const distance = hasVisibleBoundary(presentation) ? minimum(projectedRings.map((points) => minVisibleSegmentDistance(pixel, points, viewport))) : null;
|
|
440
|
+
if (distance !== null && distance <= tolerance) return {
|
|
441
|
+
kind: "polygon-boundary",
|
|
442
|
+
distance
|
|
443
|
+
};
|
|
444
|
+
if (!(projectedRings.length > 0 && pointInProjectedRing(pixel, projectedRings[0]) && !projectedRings.slice(1).some((points) => pointInProjectedRing(pixel, points)))) return null;
|
|
445
|
+
const visiblyFilled = (presentation.fill?.opacity ?? 0) > 0;
|
|
446
|
+
const inwardReach = context.pointerType === "touch" ? 36 : 24;
|
|
447
|
+
if (visiblyFilled || distance !== null && distance <= inwardReach) return {
|
|
448
|
+
kind: "polygon-interior",
|
|
449
|
+
distance
|
|
450
|
+
};
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
function bestPolygonHit(hits) {
|
|
454
|
+
if (hits.length === 0) return null;
|
|
455
|
+
const boundary = hits.filter((hit) => hit.kind === "polygon-boundary");
|
|
456
|
+
if (boundary.length > 0) return boundary.reduce((best, hit) => (hit.distance ?? Infinity) < (best.distance ?? Infinity) ? hit : best);
|
|
457
|
+
return hits[0];
|
|
458
|
+
}
|
|
459
|
+
function evaluateIcon(feature, resource, context) {
|
|
460
|
+
if (feature.geometry.type !== "Point") return null;
|
|
461
|
+
const anchor = context.toPixel(feature.geometry.coordinates);
|
|
462
|
+
if (!anchor) return null;
|
|
463
|
+
const rawScale = feature.properties?.iconScale;
|
|
464
|
+
const scale = typeof rawScale === "number" ? rawScale : 1;
|
|
465
|
+
if (!Number.isFinite(scale) || scale <= 0) return null;
|
|
466
|
+
const rawRotation = feature.properties?.iconRotation;
|
|
467
|
+
const angle = (typeof rawRotation === "number" && Number.isFinite(rawRotation) ? rawRotation : 0) - (context.bearing ?? 0);
|
|
468
|
+
const cos = Math.cos(angle);
|
|
469
|
+
const sin = Math.sin(angle);
|
|
470
|
+
const minX = -resource.anchor[0] * scale;
|
|
471
|
+
const maxX = (resource.width - resource.anchor[0]) * scale;
|
|
472
|
+
const minY = -resource.anchor[1] * scale;
|
|
473
|
+
const maxY = (resource.height - resource.anchor[1]) * scale;
|
|
474
|
+
const dx = context.pixel[0] - anchor[0];
|
|
475
|
+
const dy = context.pixel[1] - anchor[1];
|
|
476
|
+
const localX = dx * cos + dy * sin;
|
|
477
|
+
const localY = -dx * sin + dy * cos;
|
|
478
|
+
const outsideX = Math.max(minX - localX, 0, localX - maxX);
|
|
479
|
+
const outsideY = Math.max(minY - localY, 0, localY - maxY);
|
|
480
|
+
if (outsideX > context.tolerance || outsideY > context.tolerance) return null;
|
|
481
|
+
const coreRect = resource.coreRect ?? {
|
|
482
|
+
x: 0,
|
|
483
|
+
y: 0,
|
|
484
|
+
width: resource.width,
|
|
485
|
+
height: resource.height
|
|
486
|
+
};
|
|
487
|
+
const coreMinX = (coreRect.x - resource.anchor[0]) * scale;
|
|
488
|
+
const coreMaxX = (coreRect.x + coreRect.width - resource.anchor[0]) * scale;
|
|
489
|
+
const coreMinY = (coreRect.y - resource.anchor[1]) * scale;
|
|
490
|
+
const coreMaxY = (coreRect.y + coreRect.height - resource.anchor[1]) * scale;
|
|
491
|
+
const coreOutsideX = Math.max(coreMinX - localX, 0, localX - coreMaxX);
|
|
492
|
+
const coreOutsideY = Math.max(coreMinY - localY, 0, localY - coreMaxY);
|
|
493
|
+
const distance = Math.hypot(coreOutsideX, coreOutsideY);
|
|
494
|
+
const corner = (x, y) => [anchor[0] + x * cos - y * sin, anchor[1] + x * sin + y * cos];
|
|
495
|
+
return {
|
|
496
|
+
hit: {
|
|
497
|
+
kind: "icon",
|
|
498
|
+
distance
|
|
499
|
+
},
|
|
500
|
+
iconFootprint: [
|
|
501
|
+
corner(minX, minY),
|
|
502
|
+
corner(maxX, minY),
|
|
503
|
+
corner(maxX, maxY),
|
|
504
|
+
corner(minX, maxY)
|
|
505
|
+
]
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function distanceToLine(pixel, coordinates, toPixel) {
|
|
509
|
+
return minSegmentDistance(pixel, coordinates.map(toPixel));
|
|
510
|
+
}
|
|
511
|
+
/** Nearest distance from `pixel` to any segment of a pre-projected polyline. */
|
|
512
|
+
function minSegmentDistance(pixel, points) {
|
|
513
|
+
let closest = null;
|
|
514
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
515
|
+
const a = points[i];
|
|
516
|
+
const b = points[i + 1];
|
|
517
|
+
if (!a || !b) continue;
|
|
518
|
+
const distance = distanceToSegment(pixel, a, b);
|
|
519
|
+
if (distance !== null && (closest === null || distance < closest)) closest = distance;
|
|
520
|
+
}
|
|
521
|
+
return closest;
|
|
522
|
+
}
|
|
523
|
+
/** As `minSegmentDistance`, but each segment is first clipped to `viewport`. */
|
|
524
|
+
function minVisibleSegmentDistance(pixel, points, viewport) {
|
|
525
|
+
let closest = null;
|
|
526
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
527
|
+
const a = points[i];
|
|
528
|
+
const b = points[i + 1];
|
|
529
|
+
if (!a || !b) continue;
|
|
530
|
+
const visible = clipSegmentToBounds(a, b, viewport);
|
|
531
|
+
if (!visible) continue;
|
|
532
|
+
const [visibleA, visibleB] = visible;
|
|
533
|
+
const distance = visibleA[0] === visibleB[0] && visibleA[1] === visibleB[1] ? pointDistance(pixel, visibleA) : distanceToSegment(pixel, visibleA, visibleB);
|
|
534
|
+
if (distance !== null && (closest === null || distance < closest)) closest = distance;
|
|
535
|
+
}
|
|
536
|
+
return closest;
|
|
537
|
+
}
|
|
538
|
+
function clipSegmentToBounds(a, b, bounds) {
|
|
539
|
+
const dx = b[0] - a[0];
|
|
540
|
+
const dy = b[1] - a[1];
|
|
541
|
+
let start = 0;
|
|
542
|
+
let end = 1;
|
|
543
|
+
const edges = [
|
|
544
|
+
[-dx, a[0] - bounds.minX],
|
|
545
|
+
[dx, bounds.maxX - a[0]],
|
|
546
|
+
[-dy, a[1] - bounds.minY],
|
|
547
|
+
[dy, bounds.maxY - a[1]]
|
|
548
|
+
];
|
|
549
|
+
for (const [direction, offset] of edges) {
|
|
550
|
+
if (direction === 0) {
|
|
551
|
+
if (offset < 0) return null;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const ratio = offset / direction;
|
|
555
|
+
if (direction < 0) start = Math.max(start, ratio);
|
|
556
|
+
else end = Math.min(end, ratio);
|
|
557
|
+
if (start > end) return null;
|
|
558
|
+
}
|
|
559
|
+
return [[a[0] + start * dx, a[1] + start * dy], [a[0] + end * dx, a[1] + end * dy]];
|
|
560
|
+
}
|
|
561
|
+
function distanceToSegment(pixel, a, b) {
|
|
562
|
+
const dx = b[0] - a[0];
|
|
563
|
+
const dy = b[1] - a[1];
|
|
564
|
+
const lengthSq = dx * dx + dy * dy;
|
|
565
|
+
if (lengthSq === 0) return null;
|
|
566
|
+
const unclamped = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
|
|
567
|
+
const t = Math.max(0, Math.min(1, unclamped));
|
|
568
|
+
return Math.hypot(pixel[0] - (a[0] + t * dx), pixel[1] - (a[1] + t * dy));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Even-odd point-in-ring test over a pre-projected ring. Any vertex that
|
|
572
|
+
* failed to project makes the whole ring inconclusive, matching the prior
|
|
573
|
+
* "project every vertex or bail" contract.
|
|
574
|
+
*/
|
|
575
|
+
function pointInProjectedRing(pixel, points) {
|
|
576
|
+
let inside = false;
|
|
577
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
578
|
+
const pi = points[i];
|
|
579
|
+
const pj = points[j];
|
|
580
|
+
if (!pi || !pj) return false;
|
|
581
|
+
const [xi, yi] = pi;
|
|
582
|
+
const [xj, yj] = pj;
|
|
583
|
+
if (yi > pixel[1] !== yj > pixel[1] && pixel[0] < (xj - xi) * (pixel[1] - yi) / (yj - yi) + xi) inside = !inside;
|
|
584
|
+
}
|
|
585
|
+
return inside;
|
|
586
|
+
}
|
|
587
|
+
function pointInExtent(pixel, extent) {
|
|
588
|
+
if ("minX" in extent) return pixel[0] >= extent.minX && pixel[0] <= extent.maxX && pixel[1] >= extent.minY && pixel[1] <= extent.maxY;
|
|
589
|
+
const dx = pixel[0] - extent.center[0];
|
|
590
|
+
const dy = pixel[1] - extent.center[1];
|
|
591
|
+
const cos = Math.cos(-extent.angle);
|
|
592
|
+
const sin = Math.sin(-extent.angle);
|
|
593
|
+
const localX = dx * cos - dy * sin;
|
|
594
|
+
const localY = dx * sin + dy * cos;
|
|
595
|
+
return Math.abs(localX) <= extent.halfWidth && Math.abs(localY) <= extent.halfHeight;
|
|
596
|
+
}
|
|
597
|
+
function pointDistance(a, b) {
|
|
598
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
599
|
+
}
|
|
600
|
+
function minimum(values) {
|
|
601
|
+
let result = null;
|
|
602
|
+
for (const value of values) if (value !== null && (result === null || value < result)) result = value;
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
function colorOpacity(color) {
|
|
606
|
+
if (color === void 0) return 0;
|
|
607
|
+
const normalized = color.trim().toLowerCase();
|
|
608
|
+
if (normalized === "transparent") return 0;
|
|
609
|
+
if (/^#[0-9a-f]{4}$/i.test(normalized)) return parseInt(normalized[4], 16) / 15;
|
|
610
|
+
if (/^#[0-9a-f]{8}$/i.test(normalized)) return parseInt(normalized.slice(7, 9), 16) / 255;
|
|
611
|
+
const functional = normalized.match(/^[a-z]+\((.*)\)$/);
|
|
612
|
+
if (functional) {
|
|
613
|
+
const body = functional[1];
|
|
614
|
+
const slashAlpha = body.match(/\/\s*([+-]?(?:\d*\.)?\d+)(%)?\s*$/);
|
|
615
|
+
if (slashAlpha) return normalizedAlpha(slashAlpha[1], slashAlpha[2] === "%");
|
|
616
|
+
const commaParts = body.split(",").map((part) => part.trim());
|
|
617
|
+
if (commaParts.length === 4) {
|
|
618
|
+
const alpha = commaParts[3].match(/^([+-]?(?:\d*\.)?\d+)(%)?$/);
|
|
619
|
+
if (alpha) return normalizedAlpha(alpha[1], alpha[2] === "%");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return 1;
|
|
623
|
+
}
|
|
624
|
+
function normalizedAlpha(value, percentage) {
|
|
625
|
+
const alpha = Number(value);
|
|
626
|
+
if (!Number.isFinite(alpha)) return 1;
|
|
627
|
+
return Math.max(0, Math.min(1, percentage ? alpha / 100 : alpha));
|
|
628
|
+
}
|
|
629
|
+
function hasVisibleBoundary(presentation) {
|
|
630
|
+
const stroke = presentation.stroke;
|
|
631
|
+
if (!stroke || stroke.color === void 0) return false;
|
|
632
|
+
if (stroke.width !== void 0 && stroke.width <= 0) return false;
|
|
633
|
+
return colorOpacity(stroke.color) > 0;
|
|
634
|
+
}
|
|
635
|
+
function asRecord(value) {
|
|
636
|
+
return value !== null && typeof value === "object" ? value : void 0;
|
|
637
|
+
}
|
|
638
|
+
function stringValue(value) {
|
|
639
|
+
return typeof value === "string" ? value : void 0;
|
|
640
|
+
}
|
|
641
|
+
function numberValue(value) {
|
|
642
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
643
|
+
}
|
|
644
|
+
function numberArray(value) {
|
|
645
|
+
return Array.isArray(value) && value.every((item) => typeof item === "number") ? [...value] : void 0;
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
271
648
|
//#region src/feature-style.ts
|
|
272
649
|
/**
|
|
273
650
|
* Coerce a raw `properties.style` value into a `HandleStyle` for the
|
|
@@ -296,6 +673,33 @@ function resolveGroundTextSizePixels(textSizeMeters, resolution, text) {
|
|
|
296
673
|
const max = text?.textSizeMaxPixels ?? 24;
|
|
297
674
|
return Math.min(max, Math.max(min, textSizeMeters / resolution));
|
|
298
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Resolve a Text graphic's on-screen size in pixels, honoring the
|
|
678
|
+
* `textMaxSizePixels` hide threshold (ADR-0037). Returns `null` — the "hidden"
|
|
679
|
+
* signal — when the graphic should render no text this frame; adapters emit no
|
|
680
|
+
* text style for a `null`. Distinct from [[resolveGroundTextSizePixels]], which
|
|
681
|
+
* keeps the 8–24 px clamp band for amplifier labels: this helper is the Text
|
|
682
|
+
* graphic's own sizing rule.
|
|
683
|
+
*
|
|
684
|
+
* - screen-anchored (`textSizePixels`): the fixed pixel size, never scaled or
|
|
685
|
+
* hidden by zoom.
|
|
686
|
+
* - ground-anchored (`textSizeMeters`) with a `textMaxSizePixels` threshold:
|
|
687
|
+
* *unclamped* `meters / resolution` (the 8–24 band does NOT apply), hidden
|
|
688
|
+
* (`null`) once that exceeds the threshold — text shrinks freely zooming out
|
|
689
|
+
* and vanishes rather than clamps zooming in.
|
|
690
|
+
* - ground-anchored without a threshold: falls back to the clamped band.
|
|
691
|
+
* - no usable size or resolution: `null`.
|
|
692
|
+
*/
|
|
693
|
+
function resolveTextSizePixels(props, resolution) {
|
|
694
|
+
if (typeof props.textSizePixels === "number") return props.textSizePixels;
|
|
695
|
+
const meters = props.textSizeMeters;
|
|
696
|
+
if (typeof meters !== "number" || !(resolution > 0)) return null;
|
|
697
|
+
if (typeof props.textMaxSizePixels === "number") {
|
|
698
|
+
const px = meters / resolution;
|
|
699
|
+
return px > props.textMaxSizePixels ? null : px;
|
|
700
|
+
}
|
|
701
|
+
return resolveGroundTextSizePixels(meters, resolution, props);
|
|
702
|
+
}
|
|
299
703
|
//#endregion
|
|
300
704
|
//#region src/gestures/hit-test.ts
|
|
301
705
|
/** Clamped parameter t ∈ [0, 1] of `pixel` projected onto the segment a→b in pixel space, or null for a zero-length segment. */
|
|
@@ -307,41 +711,6 @@ function clampedSegmentParameter(pixel, a, b) {
|
|
|
307
711
|
const t = ((pixel[0] - a[0]) * dx + (pixel[1] - a[1]) * dy) / lengthSq;
|
|
308
712
|
return Math.max(0, Math.min(1, t));
|
|
309
713
|
}
|
|
310
|
-
/** Distance from `pixel` to the segment p1→p2 in pixel space, or null if either endpoint fails to project or the segment has zero length. */
|
|
311
|
-
function pixelDistanceToSegment(pixel, p1, p2, toPixel) {
|
|
312
|
-
const a = toPixel(p1);
|
|
313
|
-
const b = toPixel(p2);
|
|
314
|
-
if (!a || !b) return null;
|
|
315
|
-
const t = clampedSegmentParameter(pixel, a, b);
|
|
316
|
-
if (t === null) return null;
|
|
317
|
-
const ddx = pixel[0] - (a[0] + t * (b[0] - a[0]));
|
|
318
|
-
const ddy = pixel[1] - (a[1] + t * (b[1] - a[1]));
|
|
319
|
-
return Math.sqrt(ddx * ddx + ddy * ddy);
|
|
320
|
-
}
|
|
321
|
-
/** Even-odd point-in-ring test in pixel space. */
|
|
322
|
-
function pixelPointInRing(pixel, ring, toPixel) {
|
|
323
|
-
const projected = [];
|
|
324
|
-
for (const c of ring) {
|
|
325
|
-
const p = toPixel(c);
|
|
326
|
-
if (!p) return false;
|
|
327
|
-
projected.push(p);
|
|
328
|
-
}
|
|
329
|
-
const [px, py] = pixel;
|
|
330
|
-
let inside = false;
|
|
331
|
-
for (let i = 0, j = projected.length - 1; i < projected.length; j = i++) {
|
|
332
|
-
const xi = projected[i][0], yi = projected[i][1];
|
|
333
|
-
const xj = projected[j][0], yj = projected[j][1];
|
|
334
|
-
if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
|
|
335
|
-
}
|
|
336
|
-
return inside;
|
|
337
|
-
}
|
|
338
|
-
function isNearAnySegment(pixel, ring, hitTolerance, toPixel) {
|
|
339
|
-
for (let i = 0; i < ring.length - 1; i++) {
|
|
340
|
-
const d = pixelDistanceToSegment(pixel, ring[i], ring[i + 1], toPixel);
|
|
341
|
-
if (d !== null && d <= hitTolerance) return true;
|
|
342
|
-
}
|
|
343
|
-
return false;
|
|
344
|
-
}
|
|
345
714
|
/**
|
|
346
715
|
* Test whether `pixel` hits any feature in `features`, in pixel space.
|
|
347
716
|
*
|
|
@@ -354,23 +723,28 @@ function isNearAnySegment(pixel, ring, hitTolerance, toPixel) {
|
|
|
354
723
|
* Returns the first matching feature in iteration order, or null.
|
|
355
724
|
*/
|
|
356
725
|
function hitTestFeature(pixel, features, hitTolerance, toPixel) {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
726
|
+
for (const feature of features) {
|
|
727
|
+
if (!feature) continue;
|
|
728
|
+
const candidate = evaluatePickCandidates([{
|
|
729
|
+
feature,
|
|
730
|
+
visualOrder: 0,
|
|
731
|
+
...feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon" ? { presentation: {
|
|
732
|
+
stroke: {
|
|
733
|
+
color: "#000",
|
|
734
|
+
width: 1
|
|
735
|
+
},
|
|
736
|
+
fill: {
|
|
737
|
+
color: "#000",
|
|
738
|
+
opacity: 1
|
|
739
|
+
}
|
|
740
|
+
} } : {}
|
|
741
|
+
}], {
|
|
742
|
+
pixel,
|
|
743
|
+
tolerance: hitTolerance,
|
|
744
|
+
toPixel,
|
|
745
|
+
viewport: UNBOUNDED_VIEWPORT
|
|
746
|
+
});
|
|
747
|
+
if (candidate) return candidate.feature;
|
|
374
748
|
}
|
|
375
749
|
return null;
|
|
376
750
|
}
|
|
@@ -381,26 +755,24 @@ function hitTestFeature(pixel, features, hitTolerance, toPixel) {
|
|
|
381
755
|
* unscaled SVG viewport. A positive tolerance pads all four sides.
|
|
382
756
|
*/
|
|
383
757
|
function hitTestIconFeature(pixel, feature, resource, hitTolerance, toPixel, bearing = 0) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const dy = pixel[1] - anchor[1];
|
|
396
|
-
const localX = dx * cos + dy * sin;
|
|
397
|
-
const localY = -dx * sin + dy * cos;
|
|
398
|
-
const minX = -resource.anchor[0] * scale - hitTolerance;
|
|
399
|
-
const maxX = (resource.width - resource.anchor[0]) * scale + hitTolerance;
|
|
400
|
-
const minY = -resource.anchor[1] * scale - hitTolerance;
|
|
401
|
-
const maxY = (resource.height - resource.anchor[1]) * scale + hitTolerance;
|
|
402
|
-
return localX >= minX && localX <= maxX && localY >= minY && localY <= maxY;
|
|
758
|
+
return evaluatePickCandidates([{
|
|
759
|
+
feature,
|
|
760
|
+
visualOrder: 0,
|
|
761
|
+
icon: resource
|
|
762
|
+
}], {
|
|
763
|
+
pixel,
|
|
764
|
+
tolerance: hitTolerance,
|
|
765
|
+
toPixel,
|
|
766
|
+
viewport: UNBOUNDED_VIEWPORT,
|
|
767
|
+
bearing
|
|
768
|
+
}) !== null;
|
|
403
769
|
}
|
|
770
|
+
const UNBOUNDED_VIEWPORT = {
|
|
771
|
+
minX: Number.NEGATIVE_INFINITY,
|
|
772
|
+
minY: Number.NEGATIVE_INFINITY,
|
|
773
|
+
maxX: Number.POSITIVE_INFINITY,
|
|
774
|
+
maxY: Number.POSITIVE_INFINITY
|
|
775
|
+
};
|
|
404
776
|
//#endregion
|
|
405
777
|
//#region src/gestures/hit-tolerance.ts
|
|
406
778
|
const TOUCH_HIT_TOLERANCE_PX = 22;
|
|
@@ -684,6 +1056,10 @@ const PIXEL_SIZE_OPTIONS = {
|
|
|
684
1056
|
"antitank-wall": {
|
|
685
1057
|
pixels: "toothHeightPixels",
|
|
686
1058
|
meters: "toothHeight"
|
|
1059
|
+
},
|
|
1060
|
+
text: {
|
|
1061
|
+
pixels: "sizePixels",
|
|
1062
|
+
meters: "sizeMeters"
|
|
687
1063
|
}
|
|
688
1064
|
};
|
|
689
1065
|
/** The shared label-size pair every `capturesLabelSize` kind carries (ADR-0028). */
|
|
@@ -745,6 +1121,34 @@ function getSizeAnchor(measure) {
|
|
|
745
1121
|
return measureUsesAdapterResolution(measure) ? "screen" : "ground";
|
|
746
1122
|
}
|
|
747
1123
|
/**
|
|
1124
|
+
* Materialise a *pixel-authored* kind's default pixel size onto a measure that
|
|
1125
|
+
* carries neither size key, so the ADR-0020 ground bake has a pixel value to
|
|
1126
|
+
* resolve to meters at commit. A kind is pixel-authored when its default
|
|
1127
|
+
* options declare the pixel-size option but no meter counterpart (e.g. Text's
|
|
1128
|
+
* `sizePixels: 24` with no `sizeMeters`) — such a fresh draw is authored in
|
|
1129
|
+
* pixels and must bake to meters, but a default draw commits with no options,
|
|
1130
|
+
* so the pixel size would otherwise never land and the graphic would persist
|
|
1131
|
+
* screen-anchored forever. No-op when the measure already carries a size key,
|
|
1132
|
+
* the kind has no pixel-size option, or the kind's default is meter-denominated
|
|
1133
|
+
* (e.g. FLOT, whose meter default governs a default draw without any bake) —
|
|
1134
|
+
* so this never changes the committed size of the pre-existing pixel kinds.
|
|
1135
|
+
*/
|
|
1136
|
+
function withDefaultAuthoringSize(measure) {
|
|
1137
|
+
const spec = PIXEL_SIZE_OPTIONS[measure.kind];
|
|
1138
|
+
if (!spec) return measure;
|
|
1139
|
+
const options = measure.options ?? {};
|
|
1140
|
+
if (options[spec.pixels] !== void 0 || options[spec.meters] !== void 0) return measure;
|
|
1141
|
+
const defaults = getDefaultOptions(measure.kind);
|
|
1142
|
+
if (defaults[spec.meters] !== void 0 || defaults[spec.pixels] === void 0) return measure;
|
|
1143
|
+
return {
|
|
1144
|
+
...measure,
|
|
1145
|
+
options: {
|
|
1146
|
+
...options,
|
|
1147
|
+
[spec.pixels]: defaults[spec.pixels]
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
748
1152
|
* Bake a screen-anchored (pixel) size to a ground-anchored (meter) size at the
|
|
749
1153
|
* adapter's current resolution, then strip the pixel + `metersPerPixel` keys so
|
|
750
1154
|
* the result is an ordinary meters graphic carrying no pixel intent — afterward
|
|
@@ -1535,7 +1939,7 @@ function runFixedLengthDraw(options) {
|
|
|
1535
1939
|
}
|
|
1536
1940
|
function buildSnapshot() {
|
|
1537
1941
|
const finalPoints = roundControlPoints(rule ? rule.derive(committed) : committed);
|
|
1538
|
-
const
|
|
1942
|
+
const sized = withDefaultAuthoringSize(withCommittedAdapterMeasureOptions({
|
|
1539
1943
|
id: generateId(),
|
|
1540
1944
|
kind,
|
|
1541
1945
|
controlPoints: finalPoints,
|
|
@@ -1543,8 +1947,8 @@ function runFixedLengthDraw(options) {
|
|
|
1543
1947
|
...measureStyle !== void 0 ? { style: measureStyle } : {},
|
|
1544
1948
|
...measureProperties !== void 0 ? { properties: measureProperties } : {},
|
|
1545
1949
|
...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
|
|
1546
|
-
});
|
|
1547
|
-
const measure = sizeAnchor === "screen" ?
|
|
1950
|
+
}));
|
|
1951
|
+
const measure = sizeAnchor === "screen" ? sized : bakePixelSizeToGround(sized, adapter);
|
|
1548
1952
|
return {
|
|
1549
1953
|
graphic: cloneControlMeasure(measure),
|
|
1550
1954
|
render: renderMeasureForAdapter(adapter, measure, renderOpts)
|
|
@@ -1750,7 +2154,7 @@ function runVariableLengthDraw(options) {
|
|
|
1750
2154
|
if (lastSpineIdx >= 0 && canCommitNow()) for (const finishIndex of finishIndices) {
|
|
1751
2155
|
const finishPixel = adapter.getPixelFromCoordinate(committed[finishIndex]);
|
|
1752
2156
|
if (!finishPixel) continue;
|
|
1753
|
-
const tol =
|
|
2157
|
+
const tol = pickHitTolerance(event.originalEvent);
|
|
1754
2158
|
const dx = event.pixel[0] - finishPixel[0];
|
|
1755
2159
|
const dy = event.pixel[1] - finishPixel[1];
|
|
1756
2160
|
if (dx * dx + dy * dy <= tol * tol) {
|
|
@@ -1815,7 +2219,7 @@ function runVariableLengthDraw(options) {
|
|
|
1815
2219
|
renderPreview();
|
|
1816
2220
|
}
|
|
1817
2221
|
function buildSnapshot() {
|
|
1818
|
-
const
|
|
2222
|
+
const sized = withDefaultAuthoringSize(withCommittedAdapterMeasureOptions({
|
|
1819
2223
|
id: generateId(),
|
|
1820
2224
|
kind,
|
|
1821
2225
|
controlPoints: committed.map((p) => [...p]),
|
|
@@ -1823,8 +2227,8 @@ function runVariableLengthDraw(options) {
|
|
|
1823
2227
|
...measureStyle !== void 0 ? { style: measureStyle } : {},
|
|
1824
2228
|
...measureProperties !== void 0 ? { properties: measureProperties } : {},
|
|
1825
2229
|
...measureTextAmplifiers !== void 0 ? { textAmplifiers: measureTextAmplifiers } : {}
|
|
1826
|
-
});
|
|
1827
|
-
const measure = sizeAnchor === "screen" ?
|
|
2230
|
+
}));
|
|
2231
|
+
const measure = sizeAnchor === "screen" ? sized : bakePixelSizeToGround(sized, adapter);
|
|
1828
2232
|
return {
|
|
1829
2233
|
graphic: cloneControlMeasure(measure),
|
|
1830
2234
|
render: renderMeasureForAdapter(adapter, measure, renderOpts)
|
|
@@ -2182,6 +2586,21 @@ function normalizeRotation(angle) {
|
|
|
2182
2586
|
function constrainRotation(angle) {
|
|
2183
2587
|
return Math.round(angle / CONSTRAINED_ROTATION_STEP) * CONSTRAINED_ROTATION_STEP;
|
|
2184
2588
|
}
|
|
2589
|
+
//#endregion
|
|
2590
|
+
//#region src/tactical-draw/transform-box.ts
|
|
2591
|
+
/**
|
|
2592
|
+
* The uniform scale factor and rotation delta a box implies relative to a
|
|
2593
|
+
* pre-drag box, given the gesture kind. A `body` translate reports neither
|
|
2594
|
+
* (1, 0). Consumed both by point symbols (size/rotation) and by Text graphics
|
|
2595
|
+
* (folding the delta into rotation/size options); the delta is pivot-
|
|
2596
|
+
* independent so either can apply it without knowing the box center.
|
|
2597
|
+
*/
|
|
2598
|
+
function boxScaleRotation(kind, box, preDragBox) {
|
|
2599
|
+
return {
|
|
2600
|
+
scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
|
|
2601
|
+
rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2185
2604
|
function rotate(x, y, angle) {
|
|
2186
2605
|
const cos = Math.cos(angle);
|
|
2187
2606
|
const sin = Math.sin(angle);
|
|
@@ -2720,12 +3139,16 @@ function normalizeAngle(angle) {
|
|
|
2720
3139
|
* grows away from the anchor) or local −x for `"end"` (text grows back
|
|
2721
3140
|
* toward the anchor) — `rotate()` (`transform-box.ts`) already uses the
|
|
2722
3141
|
* screen (y-down) convention this offset needs.
|
|
3142
|
+
* - **Hidden**: a ground-anchored size past `textMaxSizePixels` (ADR-0037)
|
|
3143
|
+
* returns `null` — the box tracks `resolveTextSizePixels`'s hide rule
|
|
3144
|
+
* rather than keep boxing text that no adapter is drawing.
|
|
2723
3145
|
*/
|
|
2724
3146
|
function fitLabelTransformBox(input) {
|
|
2725
|
-
const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing } = input;
|
|
3147
|
+
const { text, rotation, textAnchor, textSizePixels, textSizeMeters, anchor, resolution, bearing, unclamped, textMaxSizePixels } = input;
|
|
2726
3148
|
if (!text) return null;
|
|
3149
|
+
if (typeof textMaxSizePixels === "number" && textSizePixels === void 0 && typeof textSizeMeters === "number" && typeof resolution === "number" && resolution > 0 && textSizeMeters / resolution > textMaxSizePixels) return null;
|
|
2727
3150
|
const angle = normalizeAngle(Math.PI - (rotation ?? 0) - (bearing ?? 0));
|
|
2728
|
-
const h = textSizePixels !== void 0 ? Math.max(1, textSizePixels) : textSizeMeters !== void 0 && resolution !== void 0 && resolution > 0 ? resolveGroundTextSizePixels(textSizeMeters, resolution) : 16;
|
|
3151
|
+
const h = textSizePixels !== void 0 ? Math.max(1, textSizePixels) : textSizeMeters !== void 0 && resolution !== void 0 && resolution > 0 ? unclamped ? Math.max(1, textSizeMeters / resolution) : resolveGroundTextSizePixels(textSizeMeters, resolution) : 16;
|
|
2729
3152
|
const lines = text.split("\n");
|
|
2730
3153
|
const width = lines.reduce((max, line) => Math.max(max, line.length), 0) * CHAR_WIDTH_FACTOR * h;
|
|
2731
3154
|
const height = lines.length * LINE_HEIGHT_FACTOR * h;
|
|
@@ -2760,12 +3183,47 @@ function fitLabelBoxFromFeature(feature, anchorPixel, view) {
|
|
|
2760
3183
|
textAnchor: props.textAnchor === "start" || props.textAnchor === "end" ? props.textAnchor : void 0,
|
|
2761
3184
|
textSizePixels: typeof props.textSizePixels === "number" ? props.textSizePixels : void 0,
|
|
2762
3185
|
textSizeMeters: typeof props.textSizeMeters === "number" ? props.textSizeMeters : void 0,
|
|
3186
|
+
textMaxSizePixels: typeof props.textMaxSizePixels === "number" ? props.textMaxSizePixels : void 0,
|
|
2763
3187
|
anchor: anchorPixel,
|
|
2764
3188
|
resolution: view.resolution,
|
|
2765
|
-
bearing: view.bearing
|
|
3189
|
+
bearing: view.bearing,
|
|
3190
|
+
unclamped: view.unclamped
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* Fit a `TransformBox` around a rendered **Text generic graphic** feature — the
|
|
3195
|
+
* Text analogue of the point-symbol footprint (ADR-0031): a rotated logical
|
|
3196
|
+
* rectangle centered on / shifted from the anchor per `textAlign` (via the
|
|
3197
|
+
* feature's `textAnchor` prop), sized from the text's line count and longest
|
|
3198
|
+
* line at the actual rendered height (screen `textSizePixels` as-is; ground
|
|
3199
|
+
* `textSizeMeters / resolution` unclamped). Drives the Text edit/transform box
|
|
3200
|
+
* outline, its corner-scale handles, and the pick extent, and updates live as a
|
|
3201
|
+
* rotate/scale gesture folds new rotation/size into the feature. Returns `null`
|
|
3202
|
+
* when the feature carries no text. Rotation pivots at the anchor, so the box
|
|
3203
|
+
* orbits the anchor when the text rotates — matching how the adapters render a
|
|
3204
|
+
* `textAnchor`-shifted, rotated label.
|
|
3205
|
+
*/
|
|
3206
|
+
function fitTextGraphicFootprint(feature, anchorPixel, view) {
|
|
3207
|
+
return fitLabelBoxFromFeature(feature, anchorPixel, {
|
|
3208
|
+
...view,
|
|
3209
|
+
unclamped: true
|
|
2766
3210
|
});
|
|
2767
3211
|
}
|
|
2768
3212
|
/**
|
|
3213
|
+
* Locate the single Text `Point` feature in `features`, project its anchor, and
|
|
3214
|
+
* return its {@link fitTextGraphicFootprint} box — the shared body behind the
|
|
3215
|
+
* edit controller's Text box and the group transform-controller's Text member
|
|
3216
|
+
* footprint. Returns `null` when there is no text feature or its anchor is
|
|
3217
|
+
* off-screen.
|
|
3218
|
+
*/
|
|
3219
|
+
function fitTextFootprintFromFeatures(features, toPixel, view) {
|
|
3220
|
+
const f = features.find((feat) => feat.geometry?.type === "Point" && typeof feat.properties?.text === "string");
|
|
3221
|
+
if (!f || f.geometry?.type !== "Point") return null;
|
|
3222
|
+
const anchorPixel = toPixel(f.geometry.coordinates);
|
|
3223
|
+
if (!anchorPixel) return null;
|
|
3224
|
+
return fitTextGraphicFootprint(f, anchorPixel, view);
|
|
3225
|
+
}
|
|
3226
|
+
/**
|
|
2769
3227
|
* Carry user-adjusted label anchors (and rotation overrides) through a
|
|
2770
3228
|
* transform-box gesture. Anchors are transformed in pixel space alongside the
|
|
2771
3229
|
* control points so the result remains visually rigid across map
|
|
@@ -3123,6 +3581,7 @@ function runEdit(opts) {
|
|
|
3123
3581
|
const closedRing = rule?.closedRing ?? false;
|
|
3124
3582
|
const guideEnabled = (guideOpt ?? true) && !shouldSuppressGuide(metadata);
|
|
3125
3583
|
const isAreaKind = metadata.geometry === "area";
|
|
3584
|
+
const isTextGraphic = measure.kind === "text";
|
|
3126
3585
|
const requestedModes = normaliseModes(initialModes);
|
|
3127
3586
|
const editStartPoints = measure.controlPoints.map((p) => [...p]);
|
|
3128
3587
|
const workingPoints = editStartPoints.map((p) => [...p]);
|
|
@@ -3252,6 +3711,7 @@ function runEdit(opts) {
|
|
|
3252
3711
|
* projects (e.g. the working copy renders no features this frame).
|
|
3253
3712
|
*/
|
|
3254
3713
|
function fitBoxFromFeatures(features) {
|
|
3714
|
+
if (isTextGraphic) return fitTextBox(features);
|
|
3255
3715
|
const positions = [];
|
|
3256
3716
|
for (const f of features) if (f.geometry) collectFeaturePositions(f.geometry, positions);
|
|
3257
3717
|
const pixels = [];
|
|
@@ -3262,13 +3722,28 @@ function runEdit(opts) {
|
|
|
3262
3722
|
return fitTransformBox(pixels, boxAngle, interactionStyle?.boxPadding ?? 0);
|
|
3263
3723
|
}
|
|
3264
3724
|
/**
|
|
3725
|
+
* The Text footprint box (rotated rectangle hugging the rendered text),
|
|
3726
|
+
* derived from the single Point feature's text props. Unlike geometry-fit
|
|
3727
|
+
* boxes it carries the text's own on-screen angle (so it tilts with the
|
|
3728
|
+
* rotation option) and honors `textAnchor` alignment; the session `boxAngle`
|
|
3729
|
+
* is not used for Text.
|
|
3730
|
+
*/
|
|
3731
|
+
function fitTextBox(features) {
|
|
3732
|
+
return fitTextFootprintFromFeatures(features, driver.toPixel, {
|
|
3733
|
+
resolution: adapter.getResolution(),
|
|
3734
|
+
bearing: adapter.getBearing()
|
|
3735
|
+
});
|
|
3736
|
+
}
|
|
3737
|
+
/**
|
|
3265
3738
|
* Re-fit `box` from `lastPreviewFeatures`. A no-op while a box gesture is
|
|
3266
3739
|
* in flight — that drag paints the box rigidly from its own snapshot
|
|
3267
3740
|
* (`BoxDragSnapshot.box`) instead of a re-fit, so a corner/rotate/body drag
|
|
3268
|
-
* doesn't fight its own paint.
|
|
3741
|
+
* doesn't fight its own paint. Text is the exception: its box follows the
|
|
3742
|
+
* rendered footprint live (a rotate/scale gesture changes the rendered text),
|
|
3743
|
+
* so it re-fits every frame, drag included.
|
|
3269
3744
|
*/
|
|
3270
3745
|
function refitBox() {
|
|
3271
|
-
if (activeBoxDrag) return;
|
|
3746
|
+
if (activeBoxDrag && !isTextGraphic) return;
|
|
3272
3747
|
box = fitBoxFromFeatures(lastPreviewFeatures);
|
|
3273
3748
|
}
|
|
3274
3749
|
function liftOriginalFromGraphics() {
|
|
@@ -3319,7 +3794,7 @@ function runEdit(opts) {
|
|
|
3319
3794
|
}), handleIdPrefix, interactionStyle);
|
|
3320
3795
|
if (modes.includes("labeldrag")) features.push(...labels.handleFeatures());
|
|
3321
3796
|
if (modes.includes("transform")) {
|
|
3322
|
-
const liveBox = activeBoxDrag ? activeBoxDrag.lastSnapshot.box : box;
|
|
3797
|
+
const liveBox = activeBoxDrag && !isTextGraphic ? activeBoxDrag.lastSnapshot.box : box;
|
|
3323
3798
|
if (liveBox) {
|
|
3324
3799
|
features.push(boxOutlineFeature(liveBox, dragProjection.fromPixel, `${handleIdPrefix}:box-outline`, interactionStyle?.boxOutline));
|
|
3325
3800
|
features.push(...toFeatures(boxToHandles(liveBox, dragProjection.fromPixel), handleIdPrefix, interactionStyle));
|
|
@@ -3328,6 +3803,19 @@ function runEdit(opts) {
|
|
|
3328
3803
|
ctx.writeLayer(handlesLayer, features);
|
|
3329
3804
|
}
|
|
3330
3805
|
/**
|
|
3806
|
+
* Repaint the preview and, in lockstep, the handles. `renderPreview` re-fits
|
|
3807
|
+
* `box` (for Text, the footprint depends on options — size/rotation/
|
|
3808
|
+
* alignment/text), so the transform-box outline and its corner/rotate handles
|
|
3809
|
+
* must repaint to match; otherwise they stay where the old options placed them
|
|
3810
|
+
* and the pick target (`box`) drifts out from under the visible handles
|
|
3811
|
+
* (#scale-handle desync). Used by the non-geometry option/style/amplifier
|
|
3812
|
+
* setters, which change what the preview renders without moving a point.
|
|
3813
|
+
*/
|
|
3814
|
+
function renderPreviewAndHandles() {
|
|
3815
|
+
renderPreview();
|
|
3816
|
+
renderHandlesLayer();
|
|
3817
|
+
}
|
|
3818
|
+
/**
|
|
3331
3819
|
* Builds the committed snapshot against explicit `options` rather than the
|
|
3332
3820
|
* live `workingOptions` — used to render a gesture's "previous" snapshot
|
|
3333
3821
|
* against pre-freeze options when a rotate has already merged its
|
|
@@ -3472,7 +3960,7 @@ function runEdit(opts) {
|
|
|
3472
3960
|
replaceWorkingOptions(previousOptions);
|
|
3473
3961
|
throw err;
|
|
3474
3962
|
}
|
|
3475
|
-
|
|
3963
|
+
renderPreviewAndHandles();
|
|
3476
3964
|
nonGeometryDirty = true;
|
|
3477
3965
|
dirty = true;
|
|
3478
3966
|
emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change options");
|
|
@@ -3498,7 +3986,7 @@ function runEdit(opts) {
|
|
|
3498
3986
|
currentStyle = previousStyle;
|
|
3499
3987
|
throw err;
|
|
3500
3988
|
}
|
|
3501
|
-
|
|
3989
|
+
renderPreviewAndHandles();
|
|
3502
3990
|
nonGeometryDirty = true;
|
|
3503
3991
|
dirty = true;
|
|
3504
3992
|
emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change style");
|
|
@@ -3544,7 +4032,7 @@ function runEdit(opts) {
|
|
|
3544
4032
|
workingTextAmplifiers = previousTextAmplifiers;
|
|
3545
4033
|
throw err;
|
|
3546
4034
|
}
|
|
3547
|
-
|
|
4035
|
+
renderPreviewAndHandles();
|
|
3548
4036
|
nonGeometryDirty = true;
|
|
3549
4037
|
dirty = true;
|
|
3550
4038
|
emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, "programmatic", "Change text");
|
|
@@ -3571,7 +4059,7 @@ function runEdit(opts) {
|
|
|
3571
4059
|
workingAmplifierPlacements = Object.keys(next).length > 0 ? next : void 0;
|
|
3572
4060
|
if (JSON.stringify(workingAmplifierPlacements) === JSON.stringify(previousAmplifierPlacements)) return;
|
|
3573
4061
|
const currentRendered = rebuildRendered();
|
|
3574
|
-
|
|
4062
|
+
renderPreviewAndHandles();
|
|
3575
4063
|
nonGeometryDirty = true;
|
|
3576
4064
|
dirty = true;
|
|
3577
4065
|
emitChange(previousRendered, previousPoints, previousOptions, previousStyle, previousNonGeometryDirty, sizeAnchor, currentRendered, previousTextAmplifiers, previousAmplifierPlacements, source, historyLabel);
|
|
@@ -3917,11 +4405,40 @@ function runEdit(opts) {
|
|
|
3917
4405
|
recomputeDirty();
|
|
3918
4406
|
emitTransient("move", snapshot.points, activeDrag.controlPointIndex);
|
|
3919
4407
|
}
|
|
4408
|
+
/**
|
|
4409
|
+
* Fold a box gesture's delta (the box `to` relative to `from`, given the
|
|
4410
|
+
* gesture `kind`) into a Text graphic's `rotation`/size options, on top of
|
|
4411
|
+
* `base`. A `scale` grows the active size anchor; a `rotate` adds to the
|
|
4412
|
+
* rotation. Returns `base` unchanged when the kind produces no patch. The
|
|
4413
|
+
* delta (uniform scale ratio, angle difference) is pivot-independent, so
|
|
4414
|
+
* folding it keeps the anchor point fixed while the glyph turns/grows.
|
|
4415
|
+
*/
|
|
4416
|
+
function foldTextTransform(from, to, kind, base) {
|
|
4417
|
+
const { scale, rotation } = boxScaleRotation(kind, to, from);
|
|
4418
|
+
const patch = applyBoxTransformOptions(measure.kind, base ?? {}, {
|
|
4419
|
+
scale,
|
|
4420
|
+
rotationRadians: rotation
|
|
4421
|
+
});
|
|
4422
|
+
return patch ? {
|
|
4423
|
+
...base ?? {},
|
|
4424
|
+
...patch
|
|
4425
|
+
} : base;
|
|
4426
|
+
}
|
|
3920
4427
|
function paintBoxDragSnapshot(pixel, info, constrainRotation = false) {
|
|
3921
4428
|
if (!activeBoxDrag) return;
|
|
3922
4429
|
const resolvedPixel = translatedBoxPixel(pixel, info, activeBoxDrag);
|
|
3923
4430
|
const snapshot = activeBoxDrag.fsm.move(resolvedPixel, { constrainRotation });
|
|
3924
4431
|
activeBoxDrag.lastSnapshot = snapshot;
|
|
4432
|
+
if (isTextGraphic && (activeBoxDrag.fsm.kind === "rotate" || activeBoxDrag.fsm.kind === "scale")) {
|
|
4433
|
+
workingPoints.splice(0, workingPoints.length, ...activeBoxDrag.preDragPoints);
|
|
4434
|
+
workingOptions = foldTextTransform(activeBoxDrag.preDragBox, snapshot.box, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptions);
|
|
4435
|
+
nonGeometryDirty = true;
|
|
4436
|
+
renderPreview();
|
|
4437
|
+
renderHandlesLayer();
|
|
4438
|
+
recomputeDirty();
|
|
4439
|
+
emitTransient("move", activeBoxDrag.preDragPoints, null);
|
|
4440
|
+
return;
|
|
4441
|
+
}
|
|
3925
4442
|
workingPoints.splice(0, workingPoints.length, ...snapshot.points);
|
|
3926
4443
|
workingAmplifierPlacements = transformAmplifierPlacements(activeBoxDrag.preDragAmplifierPlacements, activeBoxDrag.preDragBox, snapshot.box, driver);
|
|
3927
4444
|
renderPreview();
|
|
@@ -4012,9 +4529,15 @@ function runEdit(opts) {
|
|
|
4012
4529
|
const finalSnapshot = fsm.move(finalPixel, { constrainRotation: info?.shiftKey });
|
|
4013
4530
|
replaceWorkingPoints(preDragPoints);
|
|
4014
4531
|
workingAmplifierPlacements = preDragAmplifierPlacements;
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4532
|
+
const textOptionFold = isTextGraphic && (fsm.kind === "rotate" || fsm.kind === "scale");
|
|
4533
|
+
if (fsm.kind === "rotate" && !isTextGraphic) boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
4534
|
+
if (textOptionFold) {
|
|
4535
|
+
workingOptions = foldTextTransform(preDragBox, finalSnapshot.box, fsm.kind, preDragOptions);
|
|
4536
|
+
nonGeometryDirty = true;
|
|
4537
|
+
}
|
|
4538
|
+
const committedPoints = textOptionFold ? preDragPoints : finalSnapshot.points;
|
|
4539
|
+
emitTransient("end", committedPoints, null);
|
|
4540
|
+
commitGesture(committedPoints, preDragOptions, transformAmplifierPlacements(preDragAmplifierPlacements, preDragBox, finalSnapshot.box, driver), preDragBox.angle);
|
|
4018
4541
|
return;
|
|
4019
4542
|
}
|
|
4020
4543
|
};
|
|
@@ -4185,7 +4708,13 @@ function renderPointSymbolForAdapter(adapter, capability, symbol) {
|
|
|
4185
4708
|
svg: render.svg,
|
|
4186
4709
|
width: render.width,
|
|
4187
4710
|
height: render.height,
|
|
4188
|
-
anchor: [render.anchor.x, render.anchor.y]
|
|
4711
|
+
anchor: [render.anchor.x, render.anchor.y],
|
|
4712
|
+
coreRect: {
|
|
4713
|
+
x: render.octagonAnchor.x - render.octagonSize / 2,
|
|
4714
|
+
y: render.octagonAnchor.y - render.octagonSize / 2,
|
|
4715
|
+
width: render.octagonSize,
|
|
4716
|
+
height: render.octagonSize
|
|
4717
|
+
}
|
|
4189
4718
|
},
|
|
4190
4719
|
feature: pointSymbolFeature(symbol, render, adapter.getResolution())
|
|
4191
4720
|
};
|
|
@@ -4223,18 +4752,6 @@ function boundProperty(key, value) {
|
|
|
4223
4752
|
//#region src/tactical-draw/transform-controller.ts
|
|
4224
4753
|
/** No point symbol may scale below this factor of its own size — mirrors the box-drag floor. */
|
|
4225
4754
|
const MIN_POINT_SYMBOL_SIZE = .1;
|
|
4226
|
-
/**
|
|
4227
|
-
* The uniform scale factor and rotation delta a box implies relative to a
|
|
4228
|
-
* pre-drag box, given the gesture kind. Body translate reports neither (1, 0);
|
|
4229
|
-
* measures ignore this and take their geometry straight from the transformed
|
|
4230
|
-
* anchor points — only point symbols consume it.
|
|
4231
|
-
*/
|
|
4232
|
-
function boxScaleRotation(kind, box, preDragBox) {
|
|
4233
|
-
return {
|
|
4234
|
-
scale: kind === "scale" ? Math.max(box.halfWidth / preDragBox.halfWidth, box.halfHeight / preDragBox.halfHeight) : 1,
|
|
4235
|
-
rotation: kind === "rotate" ? box.angle - preDragBox.angle : 0
|
|
4236
|
-
};
|
|
4237
|
-
}
|
|
4238
4755
|
/** Apply a box's scale/rotation deltas to each member's pre-drag point-symbol state. */
|
|
4239
4756
|
function scalePointSymbolStates(pre, scale, rotation) {
|
|
4240
4757
|
return pre.map((state) => state ? {
|
|
@@ -4444,6 +4961,40 @@ function runGroupTransform(opts) {
|
|
|
4444
4961
|
}
|
|
4445
4962
|
return pixels;
|
|
4446
4963
|
}
|
|
4964
|
+
/** The rotated-text footprint corners for a Text member's rendered feature, or `null`. */
|
|
4965
|
+
function textFootprintCorners(features) {
|
|
4966
|
+
const box = fitTextFootprintFromFeatures(features, driver.toPixel, {
|
|
4967
|
+
resolution: adapter.getResolution(),
|
|
4968
|
+
bearing: adapter.getBearing()
|
|
4969
|
+
});
|
|
4970
|
+
return box ? boxCorners(box) : null;
|
|
4971
|
+
}
|
|
4972
|
+
/**
|
|
4973
|
+
* Fold a box gesture's delta (`box` vs the pre-drag box) into each measure
|
|
4974
|
+
* member that persists orientation/size as options (e.g. Text), off the
|
|
4975
|
+
* per-member `baseOverrides` so live ticks recompute rather than compound.
|
|
4976
|
+
* Only members whose kind returns a patch (Text) are touched — members that
|
|
4977
|
+
* carry an orientation *freeze* (Battle Position's echelon anchor) return no
|
|
4978
|
+
* patch and keep their live override untouched. Point-symbol members take the
|
|
4979
|
+
* delta through their own working state, not here.
|
|
4980
|
+
*/
|
|
4981
|
+
function applyMeasureTransformForBox(box, fromBox, kind, baseOverrides) {
|
|
4982
|
+
const { scale, rotation } = boxScaleRotation(kind, box, fromBox);
|
|
4983
|
+
if (scale === 1 && rotation === 0) return;
|
|
4984
|
+
members.forEach((member, i) => {
|
|
4985
|
+
if (isPointSymbolMember(member)) return;
|
|
4986
|
+
const base = baseOverrides[i] ?? null;
|
|
4987
|
+
const effective = mergeOptions(member.graphic.options, base);
|
|
4988
|
+
const patch = applyBoxTransformOptions(member.graphic.kind, effective, {
|
|
4989
|
+
scale,
|
|
4990
|
+
rotationRadians: rotation
|
|
4991
|
+
});
|
|
4992
|
+
if (patch) member.optionsOverride = {
|
|
4993
|
+
...base ?? {},
|
|
4994
|
+
...patch
|
|
4995
|
+
};
|
|
4996
|
+
});
|
|
4997
|
+
}
|
|
4447
4998
|
/** The live box angle: the in-flight drag's rigidly-transformed angle, or the committed `boxAngle`. */
|
|
4448
4999
|
function currentAngle() {
|
|
4449
5000
|
return activeBoxDrag ? activeBoxDrag.lastSnapshot.box.angle : boxAngle;
|
|
@@ -4483,8 +5034,9 @@ function runGroupTransform(opts) {
|
|
|
4483
5034
|
const footprint = pointSymbolFootprint(symbol, plan.render, adapter);
|
|
4484
5035
|
if (footprint) boxPixels = footprint.corners;
|
|
4485
5036
|
} else {
|
|
4486
|
-
|
|
4487
|
-
|
|
5037
|
+
const working = workingGraphicFor(i);
|
|
5038
|
+
features = renderMeasureForAdapter(adapter, working, renderOpts).features;
|
|
5039
|
+
boxPixels = (working.kind === "text" ? textFootprintCorners(features) : null) ?? pixelsFromFeatures(features);
|
|
4488
5040
|
}
|
|
4489
5041
|
} catch {
|
|
4490
5042
|
features = [];
|
|
@@ -4883,6 +5435,7 @@ function runGroupTransform(opts) {
|
|
|
4883
5435
|
activeBoxDrag.lastSnapshot = snapshot;
|
|
4884
5436
|
applyConcatenatedPoints(snapshot.points);
|
|
4885
5437
|
applyPointSymbolStateByMember(pointSymbolStateForBox(snapshot.box));
|
|
5438
|
+
applyMeasureTransformForBox(snapshot.box, activeBoxDrag.preDragBox, activeBoxDrag.fsm.kind, activeBoxDrag.preDragOptionsOverrideByMember);
|
|
4886
5439
|
applyAmplifierPlacementsByMember(activeBoxDrag.preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, activeBoxDrag.preDragBox, snapshot.box, driver)));
|
|
4887
5440
|
renderAllPreviews();
|
|
4888
5441
|
renderHandlesLayer();
|
|
@@ -4922,6 +5475,7 @@ function runGroupTransform(opts) {
|
|
|
4922
5475
|
applyAmplifierPlacementsByMember(preDragAmplifierPlacementsByMember);
|
|
4923
5476
|
if (members.some(isPointSymbolMember)) shell.suppressNextClickAway();
|
|
4924
5477
|
if (fsm.kind === "rotate") boxAngle += finalSnapshot.box.angle - preDragBox.angle;
|
|
5478
|
+
applyMeasureTransformForBox(finalSnapshot.box, preDragBox, fsm.kind, preDragOptionsOverrideByMember);
|
|
4925
5479
|
commitGesture(finalSnapshot.points, preDragOptionsOverrideByMember, preDragAmplifierPlacementsByMember.map((placements) => transformAmplifierPlacements(placements, preDragBox, finalSnapshot.box, driver)), preDragBox.angle, nextPointSymbolStateByMember);
|
|
4926
5480
|
};
|
|
4927
5481
|
const core = runEditInteraction({
|
|
@@ -5913,7 +6467,11 @@ var TacticalDraw = class {
|
|
|
5913
6467
|
* then `previewLayer`, then `graphicsLayer` — against each layer's current
|
|
5914
6468
|
* `adapter.getLayerFeatures(...)` cache. The `guideLayer` is deliberately
|
|
5915
6469
|
* excluded: it is non-interactive draw-guide scaffolding, never a
|
|
5916
|
-
* legitimate "owned" click target.
|
|
6470
|
+
* legitimate "owned" click target. Within each layer, candidates follow
|
|
6471
|
+
* the shared visible-intent ranking (direct marks, then filled interiors,
|
|
6472
|
+
* then inferred outline-only reach, distance within direct marks, then
|
|
6473
|
+
* visual topmost order) — this explicit-affordance layer order is the
|
|
6474
|
+
* tier above all of it.
|
|
5917
6475
|
*
|
|
5918
6476
|
* `preview` and `graphics` each get a label text-extent fallback after the
|
|
5919
6477
|
* geometry pass misses on that layer: for every `Point` feature carrying a
|
|
@@ -5924,7 +6482,7 @@ var TacticalDraw = class {
|
|
|
5924
6482
|
* This matters most for the active preview, since a click can land
|
|
5925
6483
|
* anywhere across a rotated/offset label during an edit.
|
|
5926
6484
|
*
|
|
5927
|
-
* `tolerance` defaults to `
|
|
6485
|
+
* `tolerance` defaults to `pickHitTolerance(options?.originalEvent)` —
|
|
5928
6486
|
* pass the host's raw click event as `originalEvent` to get the same
|
|
5929
6487
|
* pointer-type-aware tolerance (4px mouse/pen, 12px touch) `onPick` uses;
|
|
5930
6488
|
* pass an explicit `tolerance` to override it outright.
|
|
@@ -5940,7 +6498,7 @@ var TacticalDraw = class {
|
|
|
5940
6498
|
*/
|
|
5941
6499
|
ownsInteractionAt(pixel, options) {
|
|
5942
6500
|
this.assertAlive();
|
|
5943
|
-
const tolerance = options?.tolerance ??
|
|
6501
|
+
const tolerance = options?.tolerance ?? pickHitTolerance(options?.originalEvent);
|
|
5944
6502
|
const toPixel = (lonLat) => this.adapter.getPixelFromCoordinate(lonLat);
|
|
5945
6503
|
const layers = [
|
|
5946
6504
|
{
|
|
@@ -5959,20 +6517,48 @@ var TacticalDraw = class {
|
|
|
5959
6517
|
labelFallback: true
|
|
5960
6518
|
}
|
|
5961
6519
|
];
|
|
6520
|
+
const size = this.adapter.getViewportSize();
|
|
6521
|
+
const context = {
|
|
6522
|
+
pixel,
|
|
6523
|
+
pointerType: pickPointerType(options?.originalEvent),
|
|
6524
|
+
tolerance,
|
|
6525
|
+
toPixel,
|
|
6526
|
+
viewport: {
|
|
6527
|
+
minX: 0,
|
|
6528
|
+
minY: 0,
|
|
6529
|
+
maxX: size.width,
|
|
6530
|
+
maxY: size.height
|
|
6531
|
+
},
|
|
6532
|
+
bearing: this.adapter.getBearing()
|
|
6533
|
+
};
|
|
5962
6534
|
for (const { layer, layerId, labelFallback } of layers) {
|
|
5963
6535
|
const features = this.adapter.getLayerFeatures(layerId);
|
|
5964
|
-
const
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
const
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
6536
|
+
const geometryCandidate = evaluatePickCandidates(features.map((feature, index) => ({
|
|
6537
|
+
feature,
|
|
6538
|
+
visualOrder: renderOrderToVisualOrder(index, features.length)
|
|
6539
|
+
})), context);
|
|
6540
|
+
if (geometryCandidate) return this.toInteractionHit(layer, geometryCandidate.feature);
|
|
6541
|
+
if (labelFallback) {
|
|
6542
|
+
const labelCandidates = [];
|
|
6543
|
+
for (const feature of features) {
|
|
6544
|
+
if (feature.geometry?.type !== "Point") continue;
|
|
6545
|
+
if (typeof feature.properties?.text !== "string") continue;
|
|
6546
|
+
const anchorPixel = toPixel(feature.geometry.coordinates);
|
|
6547
|
+
if (!anchorPixel) continue;
|
|
6548
|
+
const box = fitLabelBoxFromFeature(feature, anchorPixel, {
|
|
6549
|
+
resolution: this.adapter.getResolution(),
|
|
6550
|
+
bearing: this.adapter.getBearing()
|
|
6551
|
+
});
|
|
6552
|
+
if (box) labelCandidates.push({
|
|
6553
|
+
feature,
|
|
6554
|
+
labelExtent: box
|
|
6555
|
+
});
|
|
6556
|
+
}
|
|
6557
|
+
const labelCandidate = evaluatePickCandidates(labelCandidates.map((candidate, index) => ({
|
|
6558
|
+
...candidate,
|
|
6559
|
+
visualOrder: renderOrderToVisualOrder(index, labelCandidates.length)
|
|
6560
|
+
})), context);
|
|
6561
|
+
if (labelCandidate) return this.toInteractionHit(layer, labelCandidate.feature);
|
|
5976
6562
|
}
|
|
5977
6563
|
}
|
|
5978
6564
|
return null;
|
|
@@ -6390,4 +6976,4 @@ function nearestWorldCopyPosition(position, refLng) {
|
|
|
6390
6976
|
return [nearestWorldCopyLng(position[0], refLng), ...position.slice(1)];
|
|
6391
6977
|
}
|
|
6392
6978
|
//#endregion
|
|
6393
|
-
export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pointSymbolFootprint, resizePointSymbolSize, resolveGroundTextSizePixels, tryControlMeasureIdFromFeature };
|
|
6979
|
+
export { BaseMapAdapter, DEFAULT_LABEL_SIZE_PIXELS, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointSymbolCapabilityRequiredError, PointerCallbackHub, TOUCH_HIT_TOLERANCE_PX, TacticalDraw, TacticalDrawAbortError, TacticalDrawDestroyedError, bindAbortable, clonePointSymbol, coerceHandleStyle, combineWithHostSignal, effectivePointSymbolPixelSize, evaluatePickCandidates, fillPatternImageDimension, fillPatternImageId, getSizeAnchor, hitTestFeature, hitTestIconFeature, ignoreAbort, isPointSymbol, isTacticalDrawAbortError, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, nearestWorldCopyPosition, pickHitTolerance, pickPointerType, pointSymbolFootprint, renderOrderToVisualOrder, resizePointSymbolSize, resolveGroundTextSizePixels, resolvePickPresentation, resolveTextSizePixels, tryControlMeasureIdFromFeature };
|