section review page
This commit is contained in:
1103
app/submited/page.tsx
Normal file
1103
app/submited/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { type CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
@@ -26,16 +26,11 @@ type MapProps = {
|
|||||||
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||||
allowGeometryEditing?: boolean;
|
allowGeometryEditing?: boolean;
|
||||||
respectBindingFilter?: boolean;
|
respectBindingFilter?: boolean;
|
||||||
};
|
height?: CSSProperties["height"];
|
||||||
|
|
||||||
type PointIconSpec = {
|
|
||||||
id: string;
|
|
||||||
fill: string;
|
|
||||||
stroke: string;
|
|
||||||
label: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||||
|
const POINT_ICON_URL = "/point.png";
|
||||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
const MAP_MIN_ZOOM = 1;
|
const MAP_MIN_ZOOM = 1;
|
||||||
const MAP_MAX_ZOOM = 10;
|
const MAP_MAX_ZOOM = 10;
|
||||||
@@ -115,59 +110,6 @@ const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
|||||||
shipping_route: true,
|
shipping_route: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const POINT_COLOR_BY_TYPE: Record<string, string> = {
|
|
||||||
person_deathplace: "#dc2626",
|
|
||||||
person_birthplace: "#2563eb",
|
|
||||||
person_activity: "#14b8a6",
|
|
||||||
temple: "#7c3aed",
|
|
||||||
capital: "#f59e0b",
|
|
||||||
city: "#0f766e",
|
|
||||||
fortress: "#b91c1c",
|
|
||||||
castle: "#6d28d9",
|
|
||||||
ruin: "#57534e",
|
|
||||||
port: "#0c4a6e",
|
|
||||||
bridge: "#9a3412",
|
|
||||||
};
|
|
||||||
|
|
||||||
const POINT_ICON_SPECS: PointIconSpec[] = [
|
|
||||||
{ id: "point-icon-person-deathplace", fill: "#dc2626", stroke: "#7f1d1d", label: "D" },
|
|
||||||
{ id: "point-icon-person-birthplace", fill: "#2563eb", stroke: "#1e3a8a", label: "B" },
|
|
||||||
{ id: "point-icon-person-activity", fill: "#14b8a6", stroke: "#134e4a", label: "A" },
|
|
||||||
{ id: "point-icon-temple", fill: "#7c3aed", stroke: "#4c1d95", label: "T" },
|
|
||||||
{ id: "point-icon-capital", fill: "#f59e0b", stroke: "#92400e", label: "Ca" },
|
|
||||||
{ id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "Ci" },
|
|
||||||
{ id: "point-icon-fortress", fill: "#b91c1c", stroke: "#7f1d1d", label: "F" },
|
|
||||||
{ id: "point-icon-castle", fill: "#6d28d9", stroke: "#4c1d95", label: "Cs" },
|
|
||||||
{ id: "point-icon-ruin", fill: "#57534e", stroke: "#292524", label: "R" },
|
|
||||||
{ id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" },
|
|
||||||
{ id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" },
|
|
||||||
|
|
||||||
// Backward-compatible icon ids used by older naming values.
|
|
||||||
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
|
|
||||||
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
|
|
||||||
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
|
||||||
{ id: "point-icon-event", fill: "#be123c", stroke: "#881337", label: "E" },
|
|
||||||
{ id: DEFAULT_POINT_ICON_ID, fill: "#475569", stroke: "#1e293b", label: "P" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const POINT_ICON_BY_TYPE: Record<string, string> = {
|
|
||||||
person_deathplace: "point-icon-person-deathplace",
|
|
||||||
person_birthplace: "point-icon-person-birthplace",
|
|
||||||
person_activity: "point-icon-person-activity",
|
|
||||||
temple: "point-icon-temple",
|
|
||||||
capital: "point-icon-capital",
|
|
||||||
city: "point-icon-city",
|
|
||||||
fortress: "point-icon-fortress",
|
|
||||||
country: "point-icon-country",
|
|
||||||
castle: "point-icon-castle",
|
|
||||||
kingdom: "point-icon-kingdom",
|
|
||||||
ruin: "point-icon-ruin",
|
|
||||||
port: "point-icon-port",
|
|
||||||
bridge: "point-icon-bridge",
|
|
||||||
region: "point-icon-region",
|
|
||||||
event: "point-icon-event",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Map({
|
export default function Map({
|
||||||
mode,
|
mode,
|
||||||
draft,
|
draft,
|
||||||
@@ -179,6 +121,7 @@ export default function Map({
|
|||||||
onUpdateFeature,
|
onUpdateFeature,
|
||||||
allowGeometryEditing = true,
|
allowGeometryEditing = true,
|
||||||
respectBindingFilter = true,
|
respectBindingFilter = true,
|
||||||
|
height = "100vh",
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
@@ -703,16 +646,7 @@ export default function Map({
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
source: "places",
|
source: "places",
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": [
|
"circle-color": "#ef4444",
|
||||||
"case",
|
|
||||||
[
|
|
||||||
"==",
|
|
||||||
["coalesce", ["get", "entity_id"], ""],
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
"#ef4444",
|
|
||||||
buildTypeMatchExpression(POINT_COLOR_BY_TYPE, "#10b981"),
|
|
||||||
],
|
|
||||||
"circle-radius": 4,
|
"circle-radius": 4,
|
||||||
"circle-stroke-color": "#ffffff",
|
"circle-stroke-color": "#ffffff",
|
||||||
"circle-stroke-width": 1,
|
"circle-stroke-width": 1,
|
||||||
@@ -720,21 +654,7 @@ export default function Map({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add type-specific point icons (country/castle/kingdom/...) and render with symbol layer.
|
addPointSymbolLayer(map);
|
||||||
const hasTypeIcons = ensurePointIcons(map);
|
|
||||||
if (hasTypeIcons && !map.getLayer("places-symbol")) {
|
|
||||||
map.addLayer({
|
|
||||||
id: "places-symbol",
|
|
||||||
type: "symbol",
|
|
||||||
source: "places",
|
|
||||||
layout: {
|
|
||||||
"icon-image": buildPointIconExpression(),
|
|
||||||
"icon-size": 0.5,
|
|
||||||
"icon-anchor": "center",
|
|
||||||
"icon-allow-overlap": true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// init drawing
|
// init drawing
|
||||||
const cleanup = initDrawing(
|
const cleanup = initDrawing(
|
||||||
@@ -917,7 +837,7 @@ export default function Map({
|
|||||||
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
|
<div style={{ width: "100%", height, position: "relative" }}>
|
||||||
<div id="map" style={{ width: "100%", height: "100%" }} />
|
<div id="map" style={{ width: "100%", height: "100%" }} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -1160,67 +1080,41 @@ function createPathArrowImageData(): ImageData | null {
|
|||||||
return ctx.getImageData(0, 0, size, size);
|
return ctx.getImageData(0, 0, size, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensurePointIcons(map: maplibregl.Map): boolean {
|
function addPointSymbolLayer(map: maplibregl.Map) {
|
||||||
let added = false;
|
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
||||||
for (const spec of POINT_ICON_SPECS) {
|
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
||||||
if (map.hasImage(spec.id)) {
|
|
||||||
added = true;
|
map.addLayer({
|
||||||
continue;
|
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");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
const imageData = createPointIconImageData(spec);
|
|
||||||
if (!imageData) continue;
|
|
||||||
map.addImage(spec.id, imageData, { pixelRatio: 2 });
|
|
||||||
added = true;
|
|
||||||
}
|
|
||||||
return added;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPointIconImageData(spec: PointIconSpec): ImageData | null {
|
async function ensurePointAssetIcon(map: maplibregl.Map): Promise<boolean> {
|
||||||
const size = 64;
|
if (map.hasImage(DEFAULT_POINT_ICON_ID)) return true;
|
||||||
const radius = 18;
|
|
||||||
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);
|
try {
|
||||||
|
const image = await map.loadImage(POINT_ICON_URL);
|
||||||
// soft shadow
|
if (!map.hasImage(DEFAULT_POINT_ICON_ID)) {
|
||||||
ctx.fillStyle = "rgba(2, 6, 23, 0.28)";
|
map.addImage(DEFAULT_POINT_ICON_ID, image.data);
|
||||||
ctx.beginPath();
|
}
|
||||||
ctx.arc(size / 2, size / 2 + 4, radius, 0, Math.PI * 2);
|
return true;
|
||||||
ctx.fill();
|
} catch (error) {
|
||||||
|
console.error(`Failed to load point icon asset: ${POINT_ICON_URL}`, error);
|
||||||
// icon body
|
return false;
|
||||||
ctx.fillStyle = spec.fill;
|
|
||||||
ctx.strokeStyle = spec.stroke;
|
|
||||||
ctx.lineWidth = 4;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// short type mark
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
ctx.font = spec.label.length > 1 ? "700 15px sans-serif" : "700 20px sans-serif";
|
|
||||||
ctx.fillText(spec.label, size / 2, size / 2 + 0.5);
|
|
||||||
|
|
||||||
return ctx.getImageData(0, 0, size, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
|
||||||
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
|
||||||
|
|
||||||
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
|
|
||||||
expression.push(typeId, iconId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expression.push(DEFAULT_POINT_ICON_ID);
|
|
||||||
return expression as maplibregl.ExpressionSpecification;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTypeMatchExpression(
|
function buildTypeMatchExpression(
|
||||||
|
|||||||
@@ -57,9 +57,11 @@ export function initSelect(
|
|||||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "select") return;
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) return;
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, {
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
layers: [...SELECTABLE_LAYERS],
|
layers: selectableLayers,
|
||||||
}) as maplibregl.MapGeoJSONFeature[];
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
if (!features.length) {
|
if (!features.length) {
|
||||||
@@ -75,11 +77,13 @@ export function initSelect(
|
|||||||
// Mở menu thao tác khi click phải lên feature.
|
// Mở menu thao tác khi click phải lên feature.
|
||||||
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "select") return;
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) return;
|
||||||
|
|
||||||
e.preventDefault(); // block browser menu
|
e.preventDefault(); // block browser menu
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, {
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
layers: [...SELECTABLE_LAYERS],
|
layers: selectableLayers,
|
||||||
}) as maplibregl.MapGeoJSONFeature[];
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
if (!features.length) return;
|
if (!features.length) return;
|
||||||
@@ -104,14 +108,23 @@ export function initSelect(
|
|||||||
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
||||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "select") return;
|
if (getMode() !== "select") return;
|
||||||
|
const selectableLayers = getSelectableLayers();
|
||||||
|
if (!selectableLayers.length) {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, {
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
layers: [...SELECTABLE_LAYERS],
|
layers: selectableLayers,
|
||||||
});
|
});
|
||||||
|
|
||||||
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectableLayers(): string[] {
|
||||||
|
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||||
|
}
|
||||||
|
|
||||||
map.on("click", onClick);
|
map.on("click", onClick);
|
||||||
map.on("mousemove", onMove);
|
map.on("mousemove", onMove);
|
||||||
if (hasContextActions) {
|
if (hasContextActions) {
|
||||||
|
|||||||
Reference in New Issue
Block a user