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;