diff --git a/src/uhm/components/editor/NewBadge.tsx b/src/uhm/components/editor/NewBadge.tsx
new file mode 100644
index 0000000..69798a3
--- /dev/null
+++ b/src/uhm/components/editor/NewBadge.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import type { CSSProperties } from "react";
+
+type Props = {
+ title?: string;
+};
+
+export default function NewBadge({ title = "Created in this session and not committed yet" }: Props) {
+ return (
+
+ new
+
+ );
+}
+
+const badgeStyle: CSSProperties = {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flex: "0 0 auto",
+ height: 17,
+ padding: "0 6px",
+ borderRadius: 999,
+ border: "1px solid rgba(45, 212, 191, 0.55)",
+ background: "rgba(20, 184, 166, 0.16)",
+ color: "#5eead4",
+ fontSize: 10,
+ fontWeight: 900,
+ lineHeight: 1,
+ textTransform: "uppercase",
+ letterSpacing: 0,
+};
diff --git a/src/uhm/lib/map/engines/circleEngine.ts b/src/uhm/lib/map/engines/circleEngine.ts
index 673b9e5..944ce65 100644
--- a/src/uhm/lib/map/engines/circleEngine.ts
+++ b/src/uhm/lib/map/engines/circleEngine.ts
@@ -1,8 +1,8 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
+import { buildCircleRing, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
-const EARTH_RADIUS_METERS = 6371008.8;
const CIRCLE_SEGMENTS = 72;
const MIN_RADIUS_METERS = 1;
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
@@ -123,6 +123,8 @@ export function initCircle(
onComplete({
type: "Polygon",
coordinates: [ring],
+ circle_center: center,
+ circle_radius: radiusMeters,
});
resetDrawingState();
};
@@ -163,85 +165,3 @@ export function initCircle(
cancel: resetDrawingState,
};
}
-
-// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
-function buildCircleRing(
- center: [number, number],
- radiusMeters: number,
- segments: number
-): [number, number][] {
- const ring: [number, number][] = [];
- for (let i = 0; i <= segments; i += 1) {
- const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
- ring.push(destinationPoint(center, radiusMeters, bearingDeg));
- }
- return ring;
-}
-
-// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
-function distanceMeters(a: [number, number], b: [number, number]): number {
- const lat1 = toRad(a[1]);
- const lat2 = toRad(b[1]);
- const dLat = lat2 - lat1; // Delta vĩ độ (radian).
- const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
-
- const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
- const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
- const h =
- sinLat * sinLat +
- Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
- const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
- return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
-}
-
-// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
-function destinationPoint(
- center: [number, number],
- distance: number,
- bearingDeg: number
-): [number, number] {
- const lat1 = toRad(center[1]);
- const lng1 = toRad(center[0]);
- const bearing = toRad(bearingDeg);
- const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
-
- const sinLat1 = Math.sin(lat1);
- const cosLat1 = Math.cos(lat1);
- const sinAngular = Math.sin(angularDistance);
- const cosAngular = Math.cos(angularDistance);
-
- const sinLat2 =
- sinLat1 * cosAngular +
- cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
- const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
-
- const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
- const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
- const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
-
- return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
-}
-
-// Chuẩn hóa kinh độ về miền [-180, 180].
-function normalizeLng(lng: number): number {
- let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
- if (normalized === -180) normalized = 180;
- return normalized;
-}
-
-// Kẹp giá trị trong đoạn [min, max].
-function clamp(value: number, min: number, max: number): number {
- if (value < min) return min;
- if (value > max) return max;
- return value;
-}
-
-// Đổi đơn vị góc từ độ sang radian.
-function toRad(value: number): number {
- return (value * Math.PI) / 180; // Đổi độ sang radian.
-}
-
-// Đổi đơn vị góc từ radian sang độ.
-function toDeg(value: number): number {
- return (value * 180) / Math.PI; // Đổi radian sang độ.
-}
diff --git a/src/uhm/lib/map/engines/editingEngine.ts b/src/uhm/lib/map/engines/editingEngine.ts
index 64d38c1..5711332 100644
--- a/src/uhm/lib/map/engines/editingEngine.ts
+++ b/src/uhm/lib/map/engines/editingEngine.ts
@@ -1,10 +1,14 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
+import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
export type EditingHandle = {
id: string | number;
ring: [number, number][];
original: Geometry;
+ isCircle?: boolean;
+ circleCenter?: [number, number];
+ circleRadius?: number;
};
export type EditingAPI = {
@@ -40,26 +44,62 @@ export function createEditingEngine(options: {
const map = mapRef.current;
if (!editing || !map) return;
- const closedRing = [...editing.ring, editing.ring[0]];
- const shape: GeoJSON.FeatureCollection = {
- type: "FeatureCollection",
- features: [
- {
- type: "Feature",
- geometry: { type: "Polygon", coordinates: [closedRing] },
- properties: {},
- },
- ],
- };
+ let shape: GeoJSON.FeatureCollection;
+ let handles: GeoJSON.FeatureCollection;
- const handles: GeoJSON.FeatureCollection = {
- type: "FeatureCollection",
- features: editing.ring.map((c, idx) => ({
- type: "Feature",
- geometry: { type: "Point", coordinates: c },
- properties: { idx },
- })),
- };
+ if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
+ const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
+ const closedRing = [...ring, ring[0]];
+ shape = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Polygon", coordinates: [closedRing] },
+ properties: {},
+ },
+ ],
+ };
+
+ // Circle handles: 0 = center, 1 = radius control
+ const radiusHandlePoint = destinationPoint(editing.circleCenter, editing.circleRadius, 90);
+ handles = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: editing.circleCenter },
+ properties: { idx: 0, type: "center" },
+ },
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: radiusHandlePoint },
+ properties: { idx: 1, type: "radius" },
+ },
+ ],
+ };
+ } else {
+ const closedRing = [...editing.ring, editing.ring[0]];
+ shape = {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Polygon", coordinates: [closedRing] },
+ properties: {},
+ },
+ ],
+ };
+
+ handles = {
+ type: "FeatureCollection",
+ features: editing.ring.map((c, idx) => ({
+ type: "Feature",
+ geometry: { type: "Point", coordinates: c },
+ properties: { idx },
+ })),
+ };
+ }
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
@@ -69,10 +109,23 @@ export function createEditingEngine(options: {
const finishEditing = () => {
const editing = editingRef.current;
if (!editing) return;
- const geometry: Geometry = {
- type: "Polygon",
- coordinates: [[...editing.ring, editing.ring[0]]],
- };
+
+ let geometry: Geometry;
+ if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
+ const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
+ geometry = {
+ type: "Polygon",
+ coordinates: [[...ring, ring[0]]],
+ circle_center: editing.circleCenter,
+ circle_radius: editing.circleRadius,
+ };
+ } else {
+ geometry = {
+ type: "Polygon",
+ coordinates: [[...editing.ring, editing.ring[0]]],
+ };
+ }
+
onUpdate(editing.id, geometry);
clearEditing();
};
@@ -85,15 +138,21 @@ export function createEditingEngine(options: {
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return;
- const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
+ const geom = feature.geometry as Geometry;
+ const coords = (geom.coordinates?.[0] ?? []) as [number, number][];
if (coords.length < 4) return;
+ const isCircle = !!geom.circle_center;
+
// remove duplicated closing point
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
editingRef.current = {
id: feature.id ?? feature.properties?.id,
ring,
- original: feature.geometry as Geometry,
+ original: geom,
+ isCircle,
+ circleCenter: geom.circle_center,
+ circleRadius: geom.circle_radius,
};
updateEditSources();
};
@@ -129,7 +188,20 @@ export function createEditingEngine(options: {
const editing = editingRef.current;
if (!drag || !editing) return;
- editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
+ if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
+ if (drag.idx === 0) {
+ // Move center
+ editing.circleCenter = [e.lngLat.lng, e.lngLat.lat];
+ } else if (drag.idx === 1) {
+ // Change radius
+ editing.circleRadius = distanceMeters(editing.circleCenter, [
+ e.lngLat.lng,
+ e.lngLat.lat,
+ ]);
+ }
+ } else {
+ editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
+ }
updateEditSources();
};
@@ -166,7 +238,7 @@ export function createEditingEngine(options: {
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
- if (!editingRef.current) return;
+ if (!editingRef.current || editingRef.current.isCircle) return;
if (!isModifierPressed(e)) return;
e.preventDefault();
const editing = editingRef.current;
diff --git a/src/uhm/lib/map/geo/geoMath.ts b/src/uhm/lib/map/geo/geoMath.ts
new file mode 100644
index 0000000..8ae758b
--- /dev/null
+++ b/src/uhm/lib/map/geo/geoMath.ts
@@ -0,0 +1,79 @@
+const EARTH_RADIUS_METERS = 6371008.8;
+
+// Đổi đơn vị góc từ độ sang radian.
+export function toRad(value: number): number {
+ return (value * Math.PI) / 180;
+}
+
+// Đổi đơn vị góc từ radian sang độ.
+export function toDeg(value: number): number {
+ return (value * 180) / Math.PI;
+}
+
+// Kẹp giá trị trong đoạn [min, max].
+export function clamp(value: number, min: number, max: number): number {
+ if (value < min) return min;
+ if (value > max) return max;
+ return value;
+}
+
+// Chuẩn hóa kinh độ về miền [-180, 180].
+export function normalizeLng(lng: number): number {
+ let normalized = ((lng + 540) % 360) - 180;
+ if (normalized === -180) normalized = 180;
+ return normalized;
+}
+
+// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
+export function distanceMeters(a: [number, number], b: [number, number]): number {
+ const lat1 = toRad(a[1]);
+ const lat2 = toRad(b[1]);
+ const dLat = lat2 - lat1;
+ const dLng = toRad(b[0] - a[0]);
+
+ const sinLat = Math.sin(dLat / 2);
+ const sinLng = Math.sin(dLng / 2);
+ const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
+ const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
+ return EARTH_RADIUS_METERS * c;
+}
+
+// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
+export function destinationPoint(
+ center: [number, number],
+ distance: number,
+ bearingDeg: number
+): [number, number] {
+ const lat1 = toRad(center[1]);
+ const lng1 = toRad(center[0]);
+ const bearing = toRad(bearingDeg);
+ const angularDistance = distance / EARTH_RADIUS_METERS;
+
+ const sinLat1 = Math.sin(lat1);
+ const cosLat1 = Math.cos(lat1);
+ const sinAngular = Math.sin(angularDistance);
+ const cosAngular = Math.cos(angularDistance);
+
+ const sinLat2 = sinLat1 * cosAngular + cosLat1 * sinAngular * Math.cos(bearing);
+ const lat2 = Math.asin(clamp(sinLat2, -1, 1));
+
+ const y = Math.sin(bearing) * sinAngular * cosLat1;
+ const x = cosAngular - sinLat1 * Math.sin(lat2);
+ const lng2 = lng1 + Math.atan2(y, x);
+
+ return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
+}
+
+// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
+export function buildCircleRing(
+ center: [number, number],
+ radiusMeters: number,
+ segments: number = 72
+): [number, number][] {
+ const ring: [number, number][] = [];
+ for (let i = 0; i <= segments; i += 1) {
+ const bearingDeg = (i / segments) * 360;
+ ring.push(destinationPoint(center, radiusMeters, bearingDeg));
+ }
+ return ring;
+}
diff --git a/src/uhm/lib/map/styles/geotypes/lineLabels.ts b/src/uhm/lib/map/styles/geotypes/lineLabels.ts
new file mode 100644
index 0000000..87f5129
--- /dev/null
+++ b/src/uhm/lib/map/styles/geotypes/lineLabels.ts
@@ -0,0 +1,44 @@
+import maplibregl, { LayerSpecification } from "maplibre-gl";
+
+const LINE_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
+ "any",
+ ["==", ["geometry-type"], "LineString"],
+ ["==", ["geometry-type"], "MultiLineString"],
+];
+
+export function getLineLabelLayers(sourceId: string): LayerSpecification[] {
+ return [
+ {
+ id: "line-labels-text",
+ type: "symbol",
+ source: sourceId,
+ filter: ["all", LINE_GEOMETRY_FILTER, ["!=", ["coalesce", ["get", "line_label"], ""], ""]],
+ layout: {
+ "symbol-placement": "line",
+ "symbol-spacing": 280,
+ "text-field": ["coalesce", ["get", "line_label"], ""],
+ "text-size": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 1, 11,
+ 4, 13,
+ 6, 15,
+ ],
+ "text-keep-upright": true,
+ "text-max-angle": 35,
+ "text-max-width": 12,
+ "text-padding": 2,
+ "text-allow-overlap": false,
+ "text-ignore-placement": false,
+ "text-optional": true,
+ },
+ paint: {
+ "text-color": "#f8fafc",
+ "text-halo-color": "#0f172a",
+ "text-halo-width": 1.4,
+ "text-halo-blur": 0.25,
+ },
+ },
+ ];
+}
diff --git a/src/uhm/lib/map/styles/geotypes/pointStyle.ts b/src/uhm/lib/map/styles/geotypes/pointStyle.ts
new file mode 100644
index 0000000..c3ff7ff
--- /dev/null
+++ b/src/uhm/lib/map/styles/geotypes/pointStyle.ts
@@ -0,0 +1,494 @@
+import maplibregl, { LayerSpecification } from "maplibre-gl";
+
+export const POINT_GEOTYPE_IDS = [
+ "person_birthplace",
+ "person_deathplace",
+ "person_activity",
+ "temple",
+ "capital",
+ "city",
+ "fortress",
+ "castle",
+ "ruin",
+ "port",
+ "bridge",
+] as const;
+
+export type PointGeotypeId = (typeof POINT_GEOTYPE_IDS)[number];
+
+type PointIconVariant = "default" | "draft";
+
+type PointLayerOptions = {
+ iconScale?: number;
+ haloRadius?: number;
+};
+
+type PointStyleConfig = {
+ fill: string;
+ rim: string;
+ iconScale: number;
+ haloRadius: number;
+ drawGlyph: (ctx: CanvasRenderingContext2D) => void;
+};
+
+const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
+const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = ["==", ["coalesce", ["get", "entity_id"], ""], ""];
+const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
+
+const ICON_CANVAS_SIZE = 64;
+const DRAFT_FILL = "#ef4444";
+const DRAFT_RIM = "#7f1d1d";
+const POINT_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
+ "any",
+ ["==", ["geometry-type"], "Point"],
+ ["==", ["geometry-type"], "MultiPoint"],
+];
+
+const POINT_STYLE_CONFIG: Record = {
+ person_birthplace: {
+ fill: "#22c55e",
+ rim: "#166534",
+ iconScale: 1,
+ haloRadius: 15,
+ drawGlyph: drawHouseGlyph,
+ },
+ person_deathplace: {
+ fill: "#b91c1c",
+ rim: "#450a0a",
+ iconScale: 1,
+ haloRadius: 15,
+ drawGlyph: drawMemorialGlyph,
+ },
+ person_activity: {
+ fill: "#f97316",
+ rim: "#9a3412",
+ iconScale: 0.98,
+ haloRadius: 14,
+ drawGlyph: drawFlagGlyph,
+ },
+ temple: {
+ fill: "#d97706",
+ rim: "#78350f",
+ iconScale: 1.02,
+ haloRadius: 15,
+ drawGlyph: drawTempleGlyph,
+ },
+ capital: {
+ fill: "#eab308",
+ rim: "#854d0e",
+ iconScale: 1.08,
+ haloRadius: 17,
+ drawGlyph: drawCrownGlyph,
+ },
+ city: {
+ fill: "#2563eb",
+ rim: "#1e3a8a",
+ iconScale: 1.02,
+ haloRadius: 15,
+ drawGlyph: drawCityGlyph,
+ },
+ fortress: {
+ fill: "#64748b",
+ rim: "#334155",
+ iconScale: 1.04,
+ haloRadius: 16,
+ drawGlyph: drawShieldGlyph,
+ },
+ castle: {
+ fill: "#7c3aed",
+ rim: "#4c1d95",
+ iconScale: 1.04,
+ haloRadius: 16,
+ drawGlyph: drawCastleGlyph,
+ },
+ ruin: {
+ fill: "#78716c",
+ rim: "#44403c",
+ iconScale: 0.98,
+ haloRadius: 14,
+ drawGlyph: drawRuinGlyph,
+ },
+ port: {
+ fill: "#0284c7",
+ rim: "#075985",
+ iconScale: 1.02,
+ haloRadius: 15,
+ drawGlyph: drawAnchorGlyph,
+ },
+ bridge: {
+ fill: "#b45309",
+ rim: "#7c2d12",
+ iconScale: 1,
+ haloRadius: 14,
+ drawGlyph: drawBridgeGlyph,
+ },
+};
+
+export function buildPointGeotypeLayers(
+ typeId: PointGeotypeId,
+ pointSourceId: string,
+ options: PointLayerOptions = {}
+): LayerSpecification[] {
+ const config = POINT_STYLE_CONFIG[typeId];
+ const haloRadius = (options.haloRadius ?? config.haloRadius) * 2;
+ const iconScale = options.iconScale ?? config.iconScale;
+
+ return [
+ {
+ id: `${typeId}-selected-halo`,
+ type: "circle",
+ source: pointSourceId,
+ filter: pointFilter(typeId),
+ paint: {
+ "circle-color": "#22c55e",
+ "circle-radius": ["case", SELECTED_EXPR, haloRadius, 0],
+ "circle-opacity": ["case", SELECTED_EXPR, 0.24, 0],
+ "circle-blur": ["case", SELECTED_EXPR, 0.8, 0],
+ "circle-stroke-color": "#14532d",
+ "circle-stroke-width": ["case", SELECTED_EXPR, 1.6, 0],
+ "circle-stroke-opacity": ["case", SELECTED_EXPR, 0.48, 0],
+ },
+ },
+ {
+ id: `${typeId}-circle`,
+ type: "symbol",
+ source: pointSourceId,
+ filter: pointFilter(typeId),
+ layout: {
+ "icon-image": pointIconExpression(typeId),
+ "icon-size": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 1, 0.96 * iconScale,
+ 4, 1.24 * iconScale,
+ 6, 1.52 * iconScale,
+ ],
+ "icon-anchor": "center",
+ "icon-allow-overlap": true,
+ "icon-ignore-placement": true,
+ "symbol-placement": "point",
+ "text-field": ["coalesce", ["get", "point_label"], ""],
+ "text-size": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 1, 11,
+ 4, 13,
+ 6, 15,
+ ],
+ "text-anchor": "bottom",
+ "text-offset": [0, -1.25],
+ "text-allow-overlap": true,
+ "text-ignore-placement": true,
+ "text-optional": true,
+ "text-max-width": 12,
+ },
+ paint: {
+ "icon-opacity": 0.98,
+ "text-color": "#f8fafc",
+ "text-halo-color": "#0f172a",
+ "text-halo-width": 1.4,
+ "text-halo-blur": 0.3,
+ },
+ },
+ ];
+}
+
+export function ensurePointGeotypeIcons(map: maplibregl.Map): boolean {
+ if (typeof document === "undefined") return false;
+
+ for (const typeId of POINT_GEOTYPE_IDS) {
+ for (const variant of ["default", "draft"] as const) {
+ const iconId = getPointIconId(typeId, variant);
+ if (map.hasImage(iconId)) continue;
+ const imageData = createPointIconImageData(typeId, variant);
+ if (!imageData) return false;
+ map.addImage(iconId, imageData, { pixelRatio: 2 });
+ }
+ }
+
+ return true;
+}
+
+function pointFilter(typeId: PointGeotypeId): maplibregl.ExpressionSpecification {
+ return ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
+}
+
+function pointIconExpression(typeId: PointGeotypeId): maplibregl.ExpressionSpecification {
+ return ["case", DRAFT_ENTITY_EXPR, getPointIconId(typeId, "draft"), getPointIconId(typeId, "default")];
+}
+
+function getPointIconId(typeId: PointGeotypeId, variant: PointIconVariant): string {
+ return `point-${typeId}-${variant}`;
+}
+
+function createPointIconImageData(typeId: PointGeotypeId, variant: PointIconVariant): ImageData | null {
+ const config = POINT_STYLE_CONFIG[typeId];
+ const palette = variant === "draft"
+ ? { fill: DRAFT_FILL, rim: DRAFT_RIM }
+ : { fill: config.fill, rim: config.rim };
+
+ const canvas = document.createElement("canvas");
+ canvas.width = ICON_CANVAS_SIZE;
+ canvas.height = ICON_CANVAS_SIZE;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ ctx.clearRect(0, 0, ICON_CANVAS_SIZE, ICON_CANVAS_SIZE);
+ drawGlyphWithOutline(ctx, palette.fill, palette.rim, () => config.drawGlyph(ctx));
+
+ return ctx.getImageData(0, 0, ICON_CANVAS_SIZE, ICON_CANVAS_SIZE);
+}
+
+function drawGlyphWithOutline(
+ ctx: CanvasRenderingContext2D,
+ fill: string,
+ rim: string,
+ draw: () => void
+) {
+ ctx.save();
+ ctx.shadowColor = "rgba(15, 23, 42, 0.35)";
+ ctx.shadowBlur = 6;
+ ctx.shadowOffsetY = 2;
+ ctx.strokeStyle = rim;
+ ctx.fillStyle = rim;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ draw();
+ ctx.restore();
+
+ ctx.save();
+ ctx.strokeStyle = fill;
+ ctx.fillStyle = fill;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ draw();
+ ctx.restore();
+}
+
+function drawHouseGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3.5;
+ ctx.beginPath();
+ ctx.moveTo(22, 34);
+ ctx.lineTo(32, 24);
+ ctx.lineTo(42, 34);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.rect(25.5, 34, 13, 9);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(32, 43);
+ ctx.lineTo(32, 36.5);
+ ctx.stroke();
+}
+
+function drawMemorialGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3.6;
+ ctx.beginPath();
+ ctx.moveTo(32, 22);
+ ctx.lineTo(32, 43);
+ ctx.moveTo(25, 28.5);
+ ctx.lineTo(39, 28.5);
+ ctx.stroke();
+
+ ctx.lineWidth = 2.4;
+ ctx.beginPath();
+ ctx.moveTo(24, 45);
+ ctx.lineTo(40, 45);
+ ctx.stroke();
+}
+
+function drawFlagGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3.2;
+ ctx.beginPath();
+ ctx.moveTo(26, 22);
+ ctx.lineTo(26, 43);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(28, 23);
+ ctx.lineTo(40, 27);
+ ctx.lineTo(28, 31);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.lineWidth = 2.4;
+ ctx.beginPath();
+ ctx.moveTo(22.5, 44.5);
+ ctx.lineTo(31, 44.5);
+ ctx.stroke();
+}
+
+function drawTempleGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(22, 30);
+ ctx.lineTo(32, 22);
+ ctx.lineTo(42, 30);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(21, 31);
+ ctx.lineTo(43, 31);
+ ctx.moveTo(23, 42);
+ ctx.lineTo(41, 42);
+ ctx.stroke();
+
+ ctx.lineWidth = 2.8;
+ for (const x of [26, 32, 38]) {
+ ctx.beginPath();
+ ctx.moveTo(x, 31);
+ ctx.lineTo(x, 42);
+ ctx.stroke();
+ }
+}
+
+function drawCrownGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(22, 41);
+ ctx.lineTo(24.5, 28);
+ ctx.lineTo(30, 34);
+ ctx.lineTo(32, 23);
+ ctx.lineTo(34, 34);
+ ctx.lineTo(39.5, 28);
+ ctx.lineTo(42, 41);
+ ctx.closePath();
+ ctx.stroke();
+
+ ctx.lineWidth = 2.6;
+ ctx.beginPath();
+ ctx.moveTo(23.5, 41.5);
+ ctx.lineTo(40.5, 41.5);
+ ctx.stroke();
+}
+
+function drawCityGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.fillRect(23, 33, 7, 10);
+ ctx.fillRect(30, 27, 6, 16);
+ ctx.fillRect(36, 30, 6, 13);
+
+ ctx.clearRect(25, 36, 1.5, 1.5);
+ ctx.clearRect(25, 39, 1.5, 1.5);
+ ctx.clearRect(32, 31, 1.5, 1.5);
+ ctx.clearRect(32, 35, 1.5, 1.5);
+ ctx.clearRect(38, 33, 1.5, 1.5);
+ ctx.clearRect(38, 37, 1.5, 1.5);
+}
+
+function drawShieldGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3.2;
+ ctx.beginPath();
+ ctx.moveTo(32, 22.5);
+ ctx.lineTo(41, 26.5);
+ ctx.lineTo(39, 37.5);
+ ctx.lineTo(32, 43);
+ ctx.lineTo(25, 37.5);
+ ctx.lineTo(23, 26.5);
+ ctx.closePath();
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(32, 25);
+ ctx.lineTo(32, 39);
+ ctx.stroke();
+}
+
+function drawCastleGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.rect(24, 31, 16, 11);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(24, 31);
+ ctx.lineTo(24, 26);
+ ctx.lineTo(28, 26);
+ ctx.lineTo(28, 29);
+ ctx.lineTo(32, 29);
+ ctx.lineTo(32, 24);
+ ctx.lineTo(36, 24);
+ ctx.lineTo(36, 29);
+ ctx.lineTo(40, 29);
+ ctx.lineTo(40, 26);
+ ctx.lineTo(44, 26);
+ ctx.lineTo(44, 31);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(32, 42);
+ ctx.lineTo(32, 34);
+ ctx.stroke();
+}
+
+function drawRuinGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.rect(26, 24, 12, 18);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(24, 24);
+ ctx.lineTo(40, 24);
+ ctx.moveTo(24, 42);
+ ctx.lineTo(40, 42);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(34, 24);
+ ctx.lineTo(31, 29);
+ ctx.lineTo(35, 33);
+ ctx.lineTo(30, 39);
+ ctx.stroke();
+}
+
+function drawAnchorGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.arc(32, 22.5, 3.5, 0, Math.PI * 2);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(32, 26.5);
+ ctx.lineTo(32, 41);
+ ctx.moveTo(24, 31.5);
+ ctx.lineTo(40, 31.5);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.arc(32, 35.5, 9, 0.2 * Math.PI, 0.8 * Math.PI);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(24.5, 38);
+ ctx.lineTo(21.5, 34);
+ ctx.moveTo(39.5, 38);
+ ctx.lineTo(42.5, 34);
+ ctx.stroke();
+}
+
+function drawBridgeGlyph(ctx: CanvasRenderingContext2D) {
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(21, 31);
+ ctx.lineTo(43, 31);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(22, 40);
+ ctx.quadraticCurveTo(32, 26, 42, 40);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.moveTo(26, 37);
+ ctx.lineTo(26, 31);
+ ctx.moveTo(32, 33.5);
+ ctx.lineTo(32, 31);
+ ctx.moveTo(38, 37);
+ ctx.lineTo(38, 31);
+ ctx.stroke();
+}
diff --git a/src/uhm/lib/map/styles/geotypes/polygonLabels.ts b/src/uhm/lib/map/styles/geotypes/polygonLabels.ts
new file mode 100644
index 0000000..cc36375
--- /dev/null
+++ b/src/uhm/lib/map/styles/geotypes/polygonLabels.ts
@@ -0,0 +1,33 @@
+import { LayerSpecification } from "maplibre-gl";
+
+export function getPolygonLabelLayers(sourceId: string): LayerSpecification[] {
+ return [
+ {
+ id: "polygon-labels-text",
+ type: "symbol",
+ source: sourceId,
+ layout: {
+ "text-field": ["coalesce", ["get", "polygon_label"], ""],
+ "text-size": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 1, 12,
+ 4, 15,
+ 6, 18,
+ ],
+ "text-anchor": "center",
+ "text-allow-overlap": true,
+ "text-ignore-placement": true,
+ "text-max-width": 14,
+ "symbol-placement": "point",
+ },
+ paint: {
+ "text-color": "#f8fafc",
+ "text-halo-color": "#0f172a",
+ "text-halo-width": 1.6,
+ "text-halo-blur": 0.35,
+ },
+ },
+ ];
+}
diff --git a/src/uhm/lib/map/styles/geotypes/styleBuilders.ts b/src/uhm/lib/map/styles/geotypes/styleBuilders.ts
new file mode 100644
index 0000000..ffdc94c
--- /dev/null
+++ b/src/uhm/lib/map/styles/geotypes/styleBuilders.ts
@@ -0,0 +1,203 @@
+import maplibregl, { LayerSpecification } from "maplibre-gl";
+
+const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
+const DRAFT_ENTITY_EXPR: maplibregl.ExpressionSpecification = ["==", ["coalesce", ["get", "entity_id"], ""], ""];
+const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
+
+const SELECTED_COLOR = "#22c55e";
+const SELECTED_STROKE = "#14532d";
+const DRAFT_COLOR = "#ef4444";
+const DRAFT_STROKE = "#7f1d1d";
+
+type ZoomStops = {
+ z1: number;
+ z4: number;
+ z6: number;
+};
+
+type LineGeotypeStyle = {
+ typeId: string;
+ color: string;
+ strokeColor?: string;
+ opacity?: number;
+ width?: ZoomStops;
+ dasharray?: number[];
+ arrow?: boolean;
+ arrowOpacity?: number;
+ arrowOutlineColor?: string;
+ arrowOutlineWidth?: ZoomStops;
+};
+
+type PolygonGeotypeStyle = {
+ typeId: string;
+ fillColor: string;
+ strokeColor: string;
+ fillOpacity: number;
+ strokeWidth?: ZoomStops;
+ dasharray?: number[];
+};
+
+const DEFAULT_LINE_WIDTH: ZoomStops = { z1: 2.2, z4: 3.2, z6: 4.2 };
+const DEFAULT_ARROW_OUTLINE_WIDTH: ZoomStops = { z1: 0.45, z4: 0.8, z6: 1.2 };
+const DEFAULT_POLYGON_STROKE_WIDTH: ZoomStops = { z1: 1.4, z4: 2, z6: 2.8 };
+const LINE_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
+ "any",
+ ["==", ["geometry-type"], "LineString"],
+ ["==", ["geometry-type"], "MultiLineString"],
+];
+const POLYGON_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
+ "any",
+ ["==", ["geometry-type"], "Polygon"],
+ ["==", ["geometry-type"], "MultiPolygon"],
+];
+
+export function buildLineGeotypeLayers(
+ sourceId: string,
+ pathArrowSourceId: string | undefined,
+ style: LineGeotypeStyle
+): LayerSpecification[] {
+ const lineLayer: LayerSpecification = {
+ id: `${style.typeId}-line`,
+ type: "line",
+ source: sourceId,
+ filter: lineFilter(style.typeId),
+ layout: {
+ "line-cap": "round",
+ "line-join": "round",
+ },
+ paint: {
+ "line-color": statusColor(style.color),
+ "line-width": widthStops(style.width ?? DEFAULT_LINE_WIDTH),
+ "line-opacity": style.opacity ?? 0.9,
+ ...(style.dasharray ? { "line-dasharray": style.dasharray } : {}),
+ },
+ };
+
+ const hitLayer: LayerSpecification = {
+ id: `${style.typeId}-hit`,
+ type: "line",
+ source: sourceId,
+ filter: lineFilter(style.typeId),
+ layout: {
+ "line-cap": "round",
+ "line-join": "round",
+ },
+ paint: {
+ "line-color": "#ffffff",
+ "line-width": widthStops({ z1: 12, z4: 18, z6: 24 }),
+ "line-opacity": 0,
+ },
+ };
+
+ if (style.arrow === false || !pathArrowSourceId) {
+ return [lineLayer, hitLayer];
+ }
+
+ return [
+ lineLayer,
+ hitLayer,
+ {
+ id: `${style.typeId}-path-arrow-fill`,
+ type: "fill",
+ source: pathArrowSourceId,
+ filter: ["==", TYPE_MATCH_EXPR, style.typeId],
+ paint: {
+ "fill-color": statusColor(style.color),
+ "fill-opacity": [
+ "case",
+ SELECTED_EXPR,
+ 0.92,
+ style.arrowOpacity ?? 0.82,
+ ],
+ },
+ },
+ {
+ id: `${style.typeId}-path-arrow-line`,
+ type: "line",
+ source: pathArrowSourceId,
+ filter: ["==", TYPE_MATCH_EXPR, style.typeId],
+ layout: {
+ "line-cap": "round",
+ "line-join": "round",
+ },
+ paint: {
+ "line-color": statusStroke(style.arrowOutlineColor ?? style.strokeColor ?? "#0f172a"),
+ "line-width": widthStops(style.arrowOutlineWidth ?? DEFAULT_ARROW_OUTLINE_WIDTH),
+ "line-opacity": 0.9,
+ },
+ },
+ ];
+}
+
+export function buildPolygonGeotypeLayers(
+ sourceId: string,
+ style: PolygonGeotypeStyle
+): LayerSpecification[] {
+ return [
+ {
+ id: `${style.typeId}-fill`,
+ type: "fill",
+ source: sourceId,
+ filter: polygonFilter(style.typeId),
+ paint: {
+ "fill-color": statusColor(style.fillColor),
+ "fill-opacity": [
+ "case",
+ SELECTED_EXPR,
+ 0.58,
+ style.fillOpacity,
+ ],
+ },
+ },
+ {
+ id: `${style.typeId}-line`,
+ type: "line",
+ source: sourceId,
+ filter: polygonFilter(style.typeId),
+ layout: {
+ "line-cap": "round",
+ "line-join": "round",
+ },
+ paint: {
+ "line-color": statusStroke(style.strokeColor),
+ "line-width": widthStops(style.strokeWidth ?? DEFAULT_POLYGON_STROKE_WIDTH),
+ "line-opacity": 0.95,
+ ...(style.dasharray ? { "line-dasharray": style.dasharray } : {}),
+ },
+ },
+ ];
+}
+
+function statusColor(normalColor: string): maplibregl.ExpressionSpecification {
+ return [
+ "case",
+ SELECTED_EXPR,
+ SELECTED_COLOR,
+ DRAFT_ENTITY_EXPR,
+ DRAFT_COLOR,
+ normalColor,
+ ];
+}
+
+function statusStroke(normalColor: string): maplibregl.ExpressionSpecification {
+ return [
+ "case",
+ SELECTED_EXPR,
+ SELECTED_STROKE,
+ DRAFT_ENTITY_EXPR,
+ DRAFT_STROKE,
+ normalColor,
+ ];
+}
+
+function lineFilter(typeId: string): maplibregl.ExpressionSpecification {
+ return ["all", LINE_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
+}
+
+function polygonFilter(typeId: string): maplibregl.ExpressionSpecification {
+ return ["all", POLYGON_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
+}
+
+function widthStops(stops: ZoomStops): maplibregl.ExpressionSpecification {
+ return ["interpolate", ["linear"], ["zoom"], 1, stops.z1, 4, stops.z4, 6, stops.z6];
+}
diff --git a/src/uhm/types/commit_snapshot.ts b/src/uhm/types/commit_snapshot.ts
index 5bf5b3d..f9742a4 100644
--- a/src/uhm/types/commit_snapshot.ts
+++ b/src/uhm/types/commit_snapshot.ts
@@ -20,12 +20,17 @@ export type CommitSnapshot = {
// ---- GeoJSON / FeatureCollection ----
export type Geometry =
- | { type: "Point"; coordinates: [number, number] }
- | { type: "MultiPoint"; coordinates: [number, number][] }
- | { type: "LineString"; coordinates: [number, number][] }
- | { type: "MultiLineString"; coordinates: [number, number][][] }
- | { type: "Polygon"; coordinates: [number, number][][] }
- | { type: "MultiPolygon"; coordinates: [number, number][][][] };
+ | ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
+ | ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
+ | ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
+ | ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
+ | ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
+ | ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
+
+export type CircleGeometryMetadata = {
+ circle_center?: [number, number];
+ circle_radius?: number;
+};
export type FeatureId = string | number;
diff --git a/src/uhm/types/geo.ts b/src/uhm/types/geo.ts
index a489ba3..3801f3f 100644
--- a/src/uhm/types/geo.ts
+++ b/src/uhm/types/geo.ts
@@ -1,12 +1,17 @@
import type { GeometryPreset } from "@/uhm/lib/map/geo/geometryTypeOptions";
export type Geometry =
- | { type: "Point"; coordinates: [number, number] }
- | { type: "MultiPoint"; coordinates: [number, number][] }
- | { type: "LineString"; coordinates: [number, number][] }
- | { type: "MultiLineString"; coordinates: [number, number][][] }
- | { type: "Polygon"; coordinates: [number, number][][] }
- | { type: "MultiPolygon"; coordinates: [number, number][][][] };
+ | ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
+ | ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
+ | ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
+ | ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
+ | ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
+ | ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
+
+export type CircleGeometryMetadata = {
+ circle_center?: [number, number];
+ circle_radius?: number;
+};
export type FeatureId = string | number;