feat: implement updating circle

This commit is contained in:
taDuc
2026-05-13 15:48:00 +07:00
parent 41e43d4974
commit 14a06af343
10 changed files with 1010 additions and 122 deletions
@@ -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,
},
},
];
}
@@ -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<PointGeotypeId, PointStyleConfig> = {
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();
}
@@ -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,
},
},
];
}
@@ -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];
}