refactor: modularize Map component logic into dedicated hooks for map instance management, layers, interactions, and state synchronization
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/uhm/lib/backgroundLayers";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/useEditorState";
|
||||
import {
|
||||
DEFAULT_POINT_ICON_ID,
|
||||
FEATURE_STATE_SOURCE_IDS,
|
||||
PATH_ARROW_ICON_ID,
|
||||
POINT_ICON_URL,
|
||||
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
|
||||
RASTER_BASE_LAYER_ID,
|
||||
RASTER_BASE_SOURCE_ID,
|
||||
} from "@/uhm/lib/map/constants";
|
||||
import { PATH_RENDER_BY_TYPE } from "@/uhm/lib/map/style";
|
||||
import { getRasterTileTemplateUrl } from "@/uhm/api/tiles";
|
||||
import { newId } from "@/uhm/lib/id";
|
||||
|
||||
export function applyBackgroundLayerVisibility(
|
||||
map: maplibregl.Map,
|
||||
visibility: BackgroundLayerVisibility
|
||||
) {
|
||||
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
if (layer.id === RASTER_BASE_LAYER_ID) continue;
|
||||
if (!map.getLayer(layer.id)) continue;
|
||||
map.setLayoutProperty(
|
||||
layer.id,
|
||||
"visibility",
|
||||
visibility[layer.id] ? "visible" : "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
||||
if (shouldShow) {
|
||||
ensureRasterBaseLayer(map);
|
||||
return;
|
||||
}
|
||||
removeRasterBaseLayer(map);
|
||||
}
|
||||
|
||||
export function ensureRasterBaseLayer(map: maplibregl.Map) {
|
||||
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
|
||||
}
|
||||
|
||||
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
|
||||
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
|
||||
: undefined;
|
||||
map.addLayer(createRasterBaseLayer(), beforeId);
|
||||
}
|
||||
|
||||
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
|
||||
}
|
||||
|
||||
export function removeRasterBaseLayer(map: maplibregl.Map) {
|
||||
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||
map.removeLayer(RASTER_BASE_LAYER_ID);
|
||||
}
|
||||
|
||||
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||
map.removeSource(RASTER_BASE_SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRasterBaseSource() {
|
||||
return {
|
||||
type: "raster" as const,
|
||||
tiles: [getRasterTileTemplateUrl()],
|
||||
tileSize: 256,
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRasterBaseLayer() {
|
||||
return {
|
||||
id: RASTER_BASE_LAYER_ID,
|
||||
type: "raster" as const,
|
||||
source: RASTER_BASE_SOURCE_ID,
|
||||
paint: {
|
||||
"raster-opacity": 0.92,
|
||||
"raster-resampling": "linear" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelectableLayers(map: maplibregl.Map): string[] {
|
||||
return [
|
||||
"countries-fill",
|
||||
"countries-line",
|
||||
"routes-line",
|
||||
"routes-path-arrow-fill",
|
||||
"routes-path-arrow-line",
|
||||
"routes-path-hit",
|
||||
"places-circle",
|
||||
"places-symbol",
|
||||
].filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||
}
|
||||
|
||||
export function filterDraftByBinding(
|
||||
fc: FeatureCollection,
|
||||
selectedFeatureIds: (string | number)[],
|
||||
highlightFeatures?: FeatureCollection | null
|
||||
): FeatureCollection {
|
||||
const selectedIds = new Set(selectedFeatureIds.map(String));
|
||||
if (highlightFeatures?.features) {
|
||||
for (const f of highlightFeatures.features) {
|
||||
if (f.properties?.id != null) selectedIds.add(String(f.properties.id));
|
||||
}
|
||||
}
|
||||
|
||||
const childIds = new Set<string>();
|
||||
for (const feature of fc.features) {
|
||||
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||
childIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIds.size === 0) {
|
||||
return { ...fc, features: fc.features.filter((f) => !childIds.has(String(f.properties.id))) };
|
||||
}
|
||||
|
||||
const selectedChildren = new Set<string>();
|
||||
for (const feature of fc.features) {
|
||||
if (selectedIds.has(String(feature.properties.id))) {
|
||||
for (const id of normalizeBindingIds(feature.properties.binding)) {
|
||||
selectedChildren.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.filter((feature) => {
|
||||
const featureId = String(feature.properties.id);
|
||||
if (selectedIds.has(featureId)) return true;
|
||||
if (selectedChildren.has(featureId)) return true;
|
||||
return !childIds.has(featureId);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterDraftByGeometryVisibility(
|
||||
fc: FeatureCollection,
|
||||
visibility: Record<string, boolean> | null | undefined
|
||||
): FeatureCollection {
|
||||
if (!visibility) return fc;
|
||||
|
||||
return {
|
||||
...fc,
|
||||
features: fc.features.filter((feature) => {
|
||||
const key = getFeatureSemanticType(feature);
|
||||
if (!key) return true;
|
||||
return visibility[key] !== false;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBindingIds(rawBinding: unknown): string[] {
|
||||
if (!Array.isArray(rawBinding)) return [];
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of rawBinding) {
|
||||
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
|
||||
const id = String(rawId).trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export function splitDraftFeatures(fc: FeatureCollection) {
|
||||
const polygons = {
|
||||
type: "FeatureCollection",
|
||||
features: fc.features.filter((f) =>
|
||||
f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
|
||||
),
|
||||
} as FeatureCollection;
|
||||
|
||||
const points = {
|
||||
type: "FeatureCollection",
|
||||
features: fc.features.filter((f) =>
|
||||
f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
|
||||
),
|
||||
} as FeatureCollection;
|
||||
|
||||
return { polygons, points };
|
||||
}
|
||||
|
||||
export function setSelectedFeatureState(
|
||||
map: maplibregl.Map,
|
||||
id: string | number | null,
|
||||
selected: boolean
|
||||
) {
|
||||
if (id === null) return;
|
||||
for (const sourceId of FEATURE_STATE_SOURCE_IDS) {
|
||||
if (!map.getSource(sourceId)) continue;
|
||||
map.setFeatureState({ source: sourceId, id }, { selected });
|
||||
}
|
||||
}
|
||||
|
||||
export function fitMapToFeatureCollection(
|
||||
map: maplibregl.Map,
|
||||
fc: FeatureCollection,
|
||||
padding?: number | maplibregl.PaddingOptions
|
||||
): boolean {
|
||||
const bbox = getFeatureCollectionBBox(fc);
|
||||
if (!bbox) return false;
|
||||
|
||||
const resolvedPadding = typeof padding === "number" || padding ? padding : 58;
|
||||
|
||||
const lngSpan = Math.abs(bbox.maxLng - bbox.minLng);
|
||||
const latSpan = Math.abs(bbox.maxLat - bbox.minLat);
|
||||
if (lngSpan < 0.000001 && latSpan < 0.000001) {
|
||||
map.easeTo({
|
||||
center: [bbox.minLng, bbox.minLat],
|
||||
zoom: 6,
|
||||
padding: resolvedPadding,
|
||||
duration: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
map.fitBounds(
|
||||
[
|
||||
[bbox.minLng, bbox.minLat],
|
||||
[bbox.maxLng, bbox.maxLat],
|
||||
],
|
||||
{
|
||||
padding: resolvedPadding,
|
||||
maxZoom: 7,
|
||||
duration: 0,
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getFeatureCollectionBBox(
|
||||
fc: FeatureCollection
|
||||
): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
|
||||
const points = fc.features.flatMap((feature) => collectCoordinatePairs(feature.geometry.coordinates));
|
||||
if (!points.length) return null;
|
||||
|
||||
let minLng = Number.POSITIVE_INFINITY;
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLng = Number.NEGATIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const [lng, lat] of points) {
|
||||
minLng = Math.min(minLng, lng);
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
|
||||
return { minLng, minLat, maxLng, maxLat };
|
||||
}
|
||||
|
||||
export function collectCoordinatePairs(value: unknown): Array<[number, number]> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number" &&
|
||||
Number.isFinite(value[0]) &&
|
||||
Number.isFinite(value[1])
|
||||
) {
|
||||
return [[value[0], value[1]]];
|
||||
}
|
||||
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||
}
|
||||
|
||||
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
|
||||
const features = fc.features
|
||||
.map((feature) => {
|
||||
if (!isPathFeature(feature) || feature.geometry.type !== "LineString") return null;
|
||||
const geometry = buildPathArrowGeometry(feature.geometry.coordinates);
|
||||
if (!geometry) return null;
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
properties: { ...feature.properties },
|
||||
geometry,
|
||||
};
|
||||
})
|
||||
.filter((feature): feature is Feature => feature !== null);
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPathFeature(feature: Feature): boolean {
|
||||
const featureType = getFeatureSemanticType(feature);
|
||||
return Boolean(featureType && PATH_RENDER_BY_TYPE[featureType]);
|
||||
}
|
||||
|
||||
export function getFeatureSemanticType(feature: Feature): string | null {
|
||||
const value = feature.properties.type || feature.properties.entity_type_id || null;
|
||||
if (!value) return null;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null {
|
||||
const sourceCoords = removeDuplicatePathCoords(coords);
|
||||
if (sourceCoords.length < 2) return null;
|
||||
|
||||
const origin = sourceCoords[0];
|
||||
const originLatRad = toRadians(origin[1]);
|
||||
const cosOriginLat = Math.max(Math.cos(originLatRad), 0.000001);
|
||||
const projected = sourceCoords.map((coord) => projectLngLat(coord, origin, cosOriginLat));
|
||||
const measured = buildMeasuredPath(projected);
|
||||
const totalLength = measured[measured.length - 1]?.distance || 0;
|
||||
if (totalLength <= 0) return null;
|
||||
|
||||
const headLength = clampNumber(totalLength * 0.24, totalLength * 0.12, totalLength * 0.45);
|
||||
const bodyEndDistance = Math.max(totalLength - headLength, totalLength * 0.35);
|
||||
const bodyPoints = measured
|
||||
.filter((point) => point.distance < bodyEndDistance)
|
||||
.map(({ x, y, distance }) => ({ x, y, distance }));
|
||||
bodyPoints.push(pointAtDistance(measured, bodyEndDistance));
|
||||
|
||||
if (bodyPoints.length < 2) return null;
|
||||
|
||||
const tailWidth = clampNumber(totalLength * 0.005, 8000, 40000);
|
||||
const shoulderWidth = clampNumber(totalLength * 0.015, 18000, 100000);
|
||||
const headWidth = shoulderWidth * 2.0;
|
||||
|
||||
const leftBody: ProjectedPoint[] = [];
|
||||
const rightBody: ProjectedPoint[] = [];
|
||||
|
||||
for (let i = 0; i < bodyPoints.length; i += 1) {
|
||||
const point = bodyPoints[i];
|
||||
const normal = normalAt(bodyPoints, i);
|
||||
const progress = bodyEndDistance > 0
|
||||
? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
|
||||
: 0;
|
||||
const width = tailWidth + (shoulderWidth - tailWidth) * progress;
|
||||
const half = width / 2;
|
||||
leftBody.push({
|
||||
x: point.x + normal.x * half,
|
||||
y: point.y + normal.y * half,
|
||||
});
|
||||
rightBody.push({
|
||||
x: point.x - normal.x * half,
|
||||
y: point.y - normal.y * half,
|
||||
});
|
||||
}
|
||||
|
||||
const base = bodyPoints[bodyPoints.length - 1];
|
||||
const tip = pointAtDistance(measured, totalLength);
|
||||
const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
|
||||
const headHalf = headWidth / 2;
|
||||
const headBaseLeft = {
|
||||
x: base.x + headNormal.x * headHalf,
|
||||
y: base.y + headNormal.y * headHalf,
|
||||
};
|
||||
const headBaseRight = {
|
||||
x: base.x - headNormal.x * headHalf,
|
||||
y: base.y - headNormal.y * headHalf,
|
||||
};
|
||||
|
||||
const ring = [
|
||||
...leftBody,
|
||||
headBaseLeft,
|
||||
{ x: tip.x, y: tip.y },
|
||||
headBaseRight,
|
||||
...rightBody.reverse(),
|
||||
leftBody[0],
|
||||
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
|
||||
|
||||
if (ring.length < 4) return null;
|
||||
return {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
};
|
||||
}
|
||||
|
||||
export type ProjectedPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type MeasuredPoint = ProjectedPoint & {
|
||||
distance: number;
|
||||
};
|
||||
|
||||
export function removeDuplicatePathCoords(coords: [number, number][]): [number, number][] {
|
||||
const result: [number, number][] = [];
|
||||
for (const coord of coords) {
|
||||
const last = result[result.length - 1];
|
||||
if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
|
||||
result.push(coord);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function projectLngLat(
|
||||
coord: [number, number],
|
||||
origin: [number, number],
|
||||
cosOriginLat: number
|
||||
): ProjectedPoint {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
return {
|
||||
x: toRadians(coord[0] - origin[0]) * earthRadiusMeters * cosOriginLat,
|
||||
y: toRadians(coord[1] - origin[1]) * earthRadiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export function unprojectLngLat(
|
||||
point: ProjectedPoint,
|
||||
origin: [number, number],
|
||||
cosOriginLat: number
|
||||
): [number, number] {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
return [
|
||||
origin[0] + toDegrees(point.x / (earthRadiusMeters * cosOriginLat)),
|
||||
origin[1] + toDegrees(point.y / earthRadiusMeters),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildMeasuredPath(points: ProjectedPoint[]): MeasuredPoint[] {
|
||||
let distance = 0;
|
||||
return points.map((point, index) => {
|
||||
if (index > 0) {
|
||||
distance += distanceProjected(points[index - 1], point);
|
||||
}
|
||||
return {
|
||||
...point,
|
||||
distance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function pointAtDistance(points: MeasuredPoint[], targetDistance: number): MeasuredPoint {
|
||||
if (targetDistance <= 0) return points[0];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const prev = points[i - 1];
|
||||
const next = points[i];
|
||||
if (targetDistance > next.distance) continue;
|
||||
const segmentLength = next.distance - prev.distance;
|
||||
const t = segmentLength > 0 ? (targetDistance - prev.distance) / segmentLength : 0;
|
||||
return {
|
||||
x: prev.x + (next.x - prev.x) * t,
|
||||
y: prev.y + (next.y - prev.y) * t,
|
||||
distance: targetDistance,
|
||||
};
|
||||
}
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
export function normalAt(points: ProjectedPoint[], index: number): ProjectedPoint {
|
||||
const prev = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
return normalFromSegment(prev, next) || { x: 0, y: 1 };
|
||||
}
|
||||
|
||||
export function normalFromSegment(a: ProjectedPoint, b: ProjectedPoint): ProjectedPoint | null {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= 0) return null;
|
||||
return {
|
||||
x: -dy / length,
|
||||
y: dx / length,
|
||||
};
|
||||
}
|
||||
|
||||
export function distanceProjected(a: ProjectedPoint, b: ProjectedPoint): number {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
export function toRadians(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function toDegrees(value: number): number {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
|
||||
export function ensurePathArrowIcon(map: maplibregl.Map): boolean {
|
||||
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
||||
const imageData = createPathArrowImageData();
|
||||
if (!imageData) return false;
|
||||
map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createPathArrowImageData(): ImageData | null {
|
||||
const size = 56;
|
||||
if (typeof document === "undefined") return null;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
ctx.strokeStyle = "#0f172a";
|
||||
ctx.fillStyle = "#38bdf8";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8, 16);
|
||||
ctx.lineTo(28, 16);
|
||||
ctx.lineTo(28, 10);
|
||||
ctx.lineTo(46, 28);
|
||||
ctx.lineTo(28, 46);
|
||||
ctx.lineTo(28, 40);
|
||||
ctx.lineTo(8, 40);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
export function addPointSymbolLayer(map: maplibregl.Map) {
|
||||
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
||||
try {
|
||||
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
||||
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
layout: {
|
||||
"icon-image": DEFAULT_POINT_ICON_ID,
|
||||
"icon-size": 0.06,
|
||||
"icon-anchor": "center",
|
||||
"icon-allow-overlap": true,
|
||||
},
|
||||
});
|
||||
|
||||
if (map.getLayer("places-circle")) {
|
||||
map.setLayoutProperty("places-circle", "visibility", "none");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Add point symbol layer skipped", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensurePointAssetIcon(map: maplibregl.Map): Promise<boolean> {
|
||||
if (map.hasImage(DEFAULT_POINT_ICON_ID)) return true;
|
||||
|
||||
try {
|
||||
const image = await map.loadImage(POINT_ICON_URL);
|
||||
if (!map.hasImage(DEFAULT_POINT_ICON_ID)) {
|
||||
map.addImage(DEFAULT_POINT_ICON_ID, image.data);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load point icon asset: ${POINT_ICON_URL}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTypeMatchExpression(
|
||||
valueByType: Record<string, string | number | boolean>,
|
||||
fallback: string | number | boolean
|
||||
): maplibregl.ExpressionSpecification {
|
||||
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
||||
|
||||
for (const [typeId, value] of Object.entries(valueByType)) {
|
||||
expression.push(typeId, value);
|
||||
}
|
||||
|
||||
expression.push(fallback);
|
||||
return expression as maplibregl.ExpressionSpecification;
|
||||
}
|
||||
|
||||
export function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
|
||||
return [
|
||||
"coalesce",
|
||||
["get", "type"],
|
||||
["get", "entity_type_id"],
|
||||
"",
|
||||
] as maplibregl.ExpressionSpecification;
|
||||
}
|
||||
|
||||
export function roundZoom(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function buildClientFeatureId(): string {
|
||||
return newId();
|
||||
}
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user