draw path | draw area | localstorage layer state | add entities property parallel | timeline bar

This commit is contained in:
taDuc
2026-04-08 20:03:16 +07:00
parent 5ac5c4c0af
commit 4969c8cc57
15 changed files with 2056 additions and 74 deletions

View File

@@ -1,5 +1,6 @@
"use client";
import { ReactNode } from "react";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
@@ -11,6 +12,7 @@ type Props = {
onToggleLayer: (id: BackgroundLayerId) => void;
onShowAll: () => void;
onHideAll: () => void;
topContent?: ReactNode;
};
export default function BackgroundLayersPanel({
@@ -18,6 +20,7 @@ export default function BackgroundLayersPanel({
onToggleLayer,
onShowAll,
onHideAll,
topContent,
}: Props) {
return (
<aside
@@ -31,6 +34,8 @@ export default function BackgroundLayersPanel({
overflowY: "auto",
}}
>
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>

View File

@@ -2,11 +2,20 @@
import { UndoAction } from "@/lib/useEditorState";
type Mode = "draw" | "select" | "idle" | "add-point";
type Mode = "draw" | "select" | "idle" | "add-point" | "add-path" | "add-circle";
type EntityOption = {
id: string;
name: string;
geometry_count?: number;
};
type Props = {
mode: Mode;
setMode: (mode: Mode) => void;
entities: EntityOption[];
selectedEntityId: string | null;
onSelectEntityId: (entityId: string | null) => void;
entityStatus?: string | null;
onUndo: () => void;
onSave: () => void;
isSaving: boolean;
@@ -17,6 +26,10 @@ type Props = {
export default function Editor({
mode,
setMode,
entities,
selectedEntityId,
onSelectEntityId,
entityStatus,
onUndo,
onSave,
isSaving,
@@ -95,9 +108,76 @@ export default function Editor({
Add point
</button>
<button
style={getButtonStyle("add-path")}
onClick={() => setMode("add-path")}
>
Add path
</button>
<button
style={getButtonStyle("add-circle")}
onClick={() => setMode("add-circle")}
>
Add circle
</button>
<div style={{ marginTop: "12px", fontSize: "14px" }}>
Mode: <b>{mode}</b>
</div>
{mode === "add-path" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
) : null}
{mode === "add-circle" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Giữ chuột trái kéo đ mở bán kính, thả chuột đ hoàn tất.
</div>
) : null}
<div
style={{
marginTop: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "13px", color: "#e2e8f0" }}>
Entity mặc đnh cho geometry mới
</div>
<select
value={selectedEntityId || ""}
onChange={(event) => onSelectEntityId(event.target.value || null)}
style={{
width: "100%",
padding: "6px 8px",
borderRadius: "4px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
fontSize: "13px",
}}
>
<option value="">Không gắn entity</option>
{entities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name}
{typeof entity.geometry_count === "number" ? ` (${entity.geometry_count})` : ""}
</option>
))}
</select>
<div style={{ marginTop: "6px", color: "#94a3b8", fontSize: "12px" }}>
Geometry mới tạo sẽ gắn sẵn entity này, bạn thể thêm nhiều entity panel bên phải.
</div>
{entityStatus ? (
<div style={{ marginTop: "6px", color: "#fca5a5", fontSize: "12px" }}>
{entityStatus}
</div>
) : null}
</div>
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
<button

View File

@@ -8,23 +8,62 @@ import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles"
import { initDrawing } from "@/lib/drawingEngine";
import { initSelect } from "@/lib/selectingEngine";
import { initPoint } from "@/lib/pointEngine";
import { initPath } from "@/lib/pathEngine";
import { initCircle } from "@/lib/circleEngine";
import { createEditingEngine } from "@/lib/editingEngine";
import { FeatureCollection, Geometry } from "@/lib/useEditorState";
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
type MapProps = {
mode: "idle" | "draw" | "select" | "add-point";
mode: "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
selectedEntityId: string | null;
selectedEntityName: string | null;
selectedEntityTypeId: string | null;
onSelectFeatureId: (id: string | number | null) => void;
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature: (id: string | number) => void;
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
};
type PointIconSpec = {
id: string;
fill: string;
stroke: string;
label: string;
};
const DEFAULT_POINT_ICON_ID = "point-icon-default";
const PATH_ARROW_ICON_ID = "path-arrow-icon";
const POINT_ICON_SPECS: PointIconSpec[] = [
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
{ id: "point-icon-castle", fill: "#7c3aed", stroke: "#4c1d95", label: "F" },
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
{ id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "T" },
{ 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> = {
country: "point-icon-country",
castle: "point-icon-castle",
kingdom: "point-icon-kingdom",
city: "point-icon-city",
region: "point-icon-region",
event: "point-icon-event",
};
export default function Map({
mode,
draft,
backgroundVisibility,
selectedEntityId,
selectedEntityName,
selectedEntityTypeId,
onSelectFeatureId,
onCreateFeature,
onDeleteFeature,
onUpdateFeature,
@@ -33,6 +72,10 @@ export default function Map({
const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
const onCreateRef = useRef(onCreateFeature);
const onDeleteRef = useRef(onDeleteFeature);
const onUpdateRef = useRef(onUpdateFeature);
@@ -43,10 +86,43 @@ export default function Map({
modeRef.current = mode;
}, [mode]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (mode !== "add-path") {
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-circle") {
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
}, [mode]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
useEffect(() => {
selectedEntityIdRef.current = selectedEntityId;
}, [selectedEntityId]);
useEffect(() => {
selectedEntityNameRef.current = selectedEntityName;
}, [selectedEntityName]);
useEffect(() => {
selectedEntityTypeIdRef.current = selectedEntityTypeId;
}, [selectedEntityTypeId]);
useEffect(() => {
onSelectFeatureIdRef.current = onSelectFeatureId;
}, [onSelectFeatureId]);
useEffect(() => {
backgroundVisibilityRef.current = backgroundVisibility;
const map = mapRef.current;
@@ -260,6 +336,7 @@ export default function Map({
const placesMinZoom = 5;
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
const hasPathArrowIcon = ensurePathArrowIcon(map);
// preview (drawing)
map.addSource("draw-preview", {
@@ -290,6 +367,71 @@ export default function Map({
},
});
map.addSource("draw-circle-preview", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [],
},
});
map.addLayer({
id: "draw-circle-preview-fill",
type: "fill",
source: "draw-circle-preview",
paint: {
"fill-color": "#0ea5e9",
"fill-opacity": 0.25,
},
});
map.addLayer({
id: "draw-circle-preview-line",
type: "line",
source: "draw-circle-preview",
paint: {
"line-color": "#0284c7",
"line-width": 2,
"line-opacity": 0.95,
},
});
map.addSource("draw-path-preview", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [],
},
});
map.addLayer({
id: "draw-path-preview-line",
type: "line",
source: "draw-path-preview",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
"line-opacity": 0.9,
"line-dasharray": [1.2, 0.9],
},
});
if (hasPathArrowIcon) {
map.addLayer({
id: "draw-path-preview-arrows",
type: "symbol",
source: "draw-path-preview",
layout: {
"symbol-placement": "line",
"symbol-spacing": 56,
"icon-image": PATH_ARROW_ICON_ID,
"icon-size": 0.45,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
});
}
// data thật
map.addSource("countries", {
type: "geojson",
@@ -305,12 +447,19 @@ export default function Map({
id: "countries-fill",
type: "fill",
source: "countries",
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false],
"#22c55e", // selected
"#f59e0b", // normal
[
"==",
["coalesce", ["get", "entity_id"], ""],
"",
],
"#ef4444", // no entity
"#f59e0b", // linked entity
],
"fill-opacity": 0.5,
},
@@ -320,12 +469,56 @@ export default function Map({
id: "countries-line",
type: "line",
source: "countries",
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"line-color": "#fbbf24",
"line-width": 2,
},
});
map.addLayer({
id: "routes-line",
type: "line",
source: "countries",
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": [
"case",
["boolean", ["feature-state", "selected"], false],
"#22c55e",
["==", ["coalesce", ["get", "entity_id"], ""], ""],
"#ef4444",
"#38bdf8",
],
"line-width": [
"interpolate",
["linear"],
["zoom"],
1, 2.2,
4, 3.2,
6, 4.2,
],
"line-opacity": 0.9,
},
});
if (hasPathArrowIcon) {
map.addLayer({
id: "routes-arrow",
type: "symbol",
source: "countries",
filter: ["==", ["geometry-type"], "LineString"],
layout: {
"symbol-placement": "line",
"symbol-spacing": 60,
"icon-image": PATH_ARROW_ICON_ID,
"icon-size": 0.5,
"icon-allow-overlap": true,
"icon-ignore-placement": true,
},
});
}
map.addSource("places", {
type: "geojson",
data: {
@@ -373,37 +566,38 @@ export default function Map({
source: "places",
minzoom: placesMinZoom,
paint: {
"circle-color": "#ef4444",
"circle-radius": 3,
"circle-color": [
"case",
[
"==",
["coalesce", ["get", "entity_id"], ""],
"",
],
"#ef4444",
"#10b981",
],
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1,
"circle-opacity": 0.85,
},
});
// load icon from /public and hide circle fallback when available
try {
const imageResponse = await map.loadImage("/point.png");
if (!map.hasImage("point-icon")) {
map.addImage("point-icon", imageResponse.data);
}
if (!map.getLayer("places-symbol")) {
map.addLayer({
id: "places-symbol",
type: "symbol",
source: "places",
minzoom: placesMinZoom,
layout: {
"icon-image": "point-icon",
"icon-size": 0.25,
"icon-anchor": "bottom",
},
});
}
map.setLayoutProperty("places-circle", "visibility", "none");
} catch (err) {
console.warn("Failed to load point icon, using circle fallback.", err);
// Add type-specific point icons (country/castle/kingdom/...) and render with symbol layer.
const hasTypeIcons = ensurePointIcons(map);
if (hasTypeIcons && !map.getLayer("places-symbol")) {
map.addLayer({
id: "places-symbol",
type: "symbol",
source: "places",
minzoom: placesMinZoom,
layout: {
"icon-image": buildPointIconExpression(),
"icon-size": 0.5,
"icon-anchor": "center",
"icon-allow-overlap": true,
},
});
}
// init drawing
@@ -414,7 +608,13 @@ export default function Map({
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
type: "Feature",
properties: { id, kind: "country" },
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
},
geometry,
});
}
@@ -426,9 +626,11 @@ export default function Map({
(id: string | number) => {
// ensure edit overlays are cleared when a feature gets removed
editingEngineRef.current?.clearEditing();
onSelectFeatureIdRef.current?.(null);
onDeleteRef.current(id);
},
(feature) => editingEngineRef.current?.beginEditing(feature)
(feature) => editingEngineRef.current?.beginEditing(feature),
(id) => onSelectFeatureIdRef.current?.(id)
);
const cleanupPoint = initPoint(
@@ -438,12 +640,58 @@ export default function Map({
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
type: "Feature",
properties: { id, kind: "place" },
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
},
geometry,
});
}
);
const cleanupPath = initPath(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
type: "Feature",
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
},
geometry,
});
}
);
const cleanupCircle = initCircle(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
type: "Feature",
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
},
geometry,
});
}
);
map.on("remove", cleanupCircle);
map.on("remove", cleanupPath);
map.on("remove", cleanupPoint);
map.on("remove", cleanupSelect);
@@ -471,7 +719,7 @@ export default function Map({
}
}, [draft, applyDraftToMap]);
return <div id="map" style={{ flex: 1, height: "100vh" }} />;
return <div id="map" style={{ width: "100%", height: "100vh" }} />;
}
function applyBackgroundLayerVisibility(
@@ -501,3 +749,105 @@ function splitDraftFeatures(fc: FeatureCollection) {
return { polygons, points };
}
function ensurePathArrowIcon(map: maplibregl.Map): boolean {
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
const imageData = createPathArrowImageData();
if (!imageData) return false;
map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 });
return true;
}
function createPathArrowImageData(): ImageData | null {
const size = 56;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.clearRect(0, 0, size, size);
ctx.strokeStyle = "#0f172a";
ctx.fillStyle = "#38bdf8";
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(8, 16);
ctx.lineTo(28, 16);
ctx.lineTo(28, 10);
ctx.lineTo(46, 28);
ctx.lineTo(28, 46);
ctx.lineTo(28, 40);
ctx.lineTo(8, 40);
ctx.closePath();
ctx.fill();
ctx.stroke();
return ctx.getImageData(0, 0, size, size);
}
function ensurePointIcons(map: maplibregl.Map): boolean {
let added = false;
for (const spec of POINT_ICON_SPECS) {
if (map.hasImage(spec.id)) {
added = true;
continue;
}
const imageData = createPointIconImageData(spec);
if (!imageData) continue;
map.addImage(spec.id, imageData, { pixelRatio: 2 });
added = true;
}
return added;
}
function createPointIconImageData(spec: PointIconSpec): ImageData | null {
const size = 64;
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);
// soft shadow
ctx.fillStyle = "rgba(2, 6, 23, 0.28)";
ctx.beginPath();
ctx.arc(size / 2, size / 2 + 4, radius, 0, Math.PI * 2);
ctx.fill();
// icon body
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 = "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", ["coalesce", ["get", "entity_type_id"], ""]];
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;
}

269
components/TimelineBar.tsx Normal file
View File

@@ -0,0 +1,269 @@
"use client";
type Props = {
minYear: number;
maxYear: number;
windowStartYear: number;
windowEndYear: number;
onWindowStartYearChange: (year: number) => void;
onWindowEndYearChange: (year: number) => void;
year: number;
onYearChange: (year: number) => void;
isLoading: boolean;
disabled: boolean;
statusText?: string | null;
};
export default function TimelineBar({
minYear,
maxYear,
windowStartYear,
windowEndYear,
onWindowStartYearChange,
onWindowEndYearChange,
year,
onYearChange,
isLoading,
disabled,
statusText,
}: Props) {
const lower = Math.min(minYear, maxYear);
const upper = Math.max(minYear, maxYear);
const globalLocked = lower === upper;
const effectiveDisabled = disabled || globalLocked;
const safeWindowStart = clampYear(windowStartYear, lower, upper);
const safeWindowEnd = clampYear(windowEndYear, safeWindowStart, upper);
const windowLocked = safeWindowStart === safeWindowEnd;
const safeYear = clampYear(year, safeWindowStart, safeWindowEnd);
const pointDisabled = effectiveDisabled || windowLocked;
const windowStartPercent = toPercent(safeWindowStart, lower, upper);
const windowEndPercent = toPercent(safeWindowEnd, lower, upper);
const helperText = isLoading
? "Đang tải geometry theo mốc thời gian..."
: statusText || (windowLocked ? "Khoảng lớn đang thu về một mốc duy nhất." : "Kéo mốc nhỏ để query trong khoảng lớn.");
return (
<div
style={{
position: "absolute",
left: "18px",
right: "18px",
bottom: "16px",
zIndex: 10,
background: "rgba(15, 23, 42, 0.9)",
border: "1px solid rgba(148, 163, 184, 0.3)",
borderRadius: "10px",
padding: "12px 14px",
color: "#e2e8f0",
backdropFilter: "blur(2px)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
gap: "8px",
}}
>
<span style={{ fontSize: "13px", fontWeight: 600, letterSpacing: "0.02em" }}>
Timeline
</span>
<span style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
{formatYear(safeYear)}
</span>
</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", marginBottom: "6px" }}>
Khoảng thời gian lớn
</div>
<div
className="dual-range"
style={{
opacity: effectiveDisabled ? 0.6 : 1,
}}
>
<div className="dual-range-track" />
<div
className="dual-range-selected"
style={{
left: `${windowStartPercent}%`,
width: `${Math.max(windowEndPercent - windowStartPercent, 0)}%`,
}}
/>
<input
className="dual-range-input"
type="range"
min={lower}
max={upper}
step={1}
value={safeWindowStart}
onChange={(event) =>
onWindowStartYearChange(
Math.min(Number(event.target.value), safeWindowEnd)
)
}
disabled={effectiveDisabled}
aria-label="Timeline window start"
/>
<input
className="dual-range-input"
type="range"
min={lower}
max={upper}
step={1}
value={safeWindowEnd}
onChange={(event) =>
onWindowEndYearChange(
Math.max(Number(event.target.value), safeWindowStart)
)
}
disabled={effectiveDisabled}
aria-label="Timeline window end"
/>
</div>
<div
style={{
marginTop: "6px",
display: "flex",
justifyContent: "space-between",
fontSize: "12px",
color: "#94a3b8",
}}
>
<span>{formatYear(safeWindowStart)}</span>
<span>{formatYear(safeWindowEnd)}</span>
</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
Mốc thời gian chi tiết
</div>
<input
type="range"
min={safeWindowStart}
max={safeWindowEnd}
step={1}
value={safeYear}
onChange={(event) => onYearChange(Number(event.target.value))}
disabled={pointDisabled}
aria-label="Timeline year"
style={{
width: "100%",
accentColor: "#22c55e",
cursor: pointDisabled ? "not-allowed" : "pointer",
opacity: pointDisabled ? 0.6 : 1,
}}
/>
<div
style={{
marginTop: "8px",
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
alignItems: "center",
columnGap: "10px",
fontSize: "12px",
}}
>
<span style={{ color: "#94a3b8" }}>{formatYear(safeWindowStart)}</span>
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
{helperText}
</span>
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(safeWindowEnd)}</span>
</div>
<style jsx>{`
.dual-range {
position: relative;
height: 22px;
}
.dual-range-track {
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.35);
}
.dual-range-selected {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 4px;
border-radius: 999px;
background: #22c55e;
}
.dual-range-input {
pointer-events: none;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 22px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
}
.dual-range-input::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
.dual-range-input::-webkit-slider-thumb {
pointer-events: auto;
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -5px;
border-radius: 999px;
border: 2px solid #0f172a;
background: #22c55e;
cursor: pointer;
}
.dual-range-input::-moz-range-track {
height: 4px;
background: transparent;
}
.dual-range-input::-moz-range-thumb {
pointer-events: auto;
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid #0f172a;
background: #22c55e;
cursor: pointer;
}
`}</style>
</div>
);
}
function clampYear(year: number, minYear: number, maxYear: number): number {
if (year < minYear) return minYear;
if (year > maxYear) return maxYear;
return year;
}
function formatYear(year: number): string {
if (year < 0) {
return `${Math.abs(year)} TCN`;
}
return `${year}`;
}
function toPercent(value: number, minValue: number, maxValue: number): number {
if (maxValue <= minValue) return 0;
return ((value - minValue) / (maxValue - minValue)) * 100;
}