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

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;
}