refactor: replace CanvasTimelineRuler with native range input slider in TimelineBar
This commit is contained in:
@@ -793,11 +793,11 @@ function EditorPageContent() {
|
|||||||
|
|
||||||
const filteredDraft = activeTimelineFilterEnabled
|
const filteredDraft = activeTimelineFilterEnabled
|
||||||
? {
|
? {
|
||||||
...activeDraft,
|
...activeDraft,
|
||||||
features: activeDraft.features.filter((feature) =>
|
features: activeDraft.features.filter((feature) =>
|
||||||
isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear)))
|
isFeatureVisibleAtYear(feature, clampYearToFixedRange(Math.trunc(activeTimelineYear)))
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: activeDraft;
|
: activeDraft;
|
||||||
|
|
||||||
if (viewMode === "local") {
|
if (viewMode === "local") {
|
||||||
@@ -2968,6 +2968,13 @@ function EditorPageContent() {
|
|||||||
changesCount={pendingSaveCount}
|
changesCount={pendingSaveCount}
|
||||||
undoStack={editor.undoStack}
|
undoStack={editor.undoStack}
|
||||||
width={leftPanelWidth}
|
width={leftPanelWidth}
|
||||||
|
imageOverlay={imageOverlay}
|
||||||
|
onPickImageOverlay={handlePickImageOverlay}
|
||||||
|
onPasteImageOverlay={handlePasteImageOverlay}
|
||||||
|
imageOverlayKeyboardEnabled={imageOverlayKeyboardEnabled}
|
||||||
|
onImageOverlayKeyboardEnabledChange={setImageOverlayKeyboardEnabled}
|
||||||
|
onImageOverlayOpacityChange={handleImageOverlayOpacityChange}
|
||||||
|
onRemoveImageOverlay={handleRemoveImageOverlay}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
|
|||||||
@@ -17,9 +17,35 @@
|
|||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes borderPulse {
|
||||||
|
0% {
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px -10px rgba(0, 0, 0, 0.15),
|
||||||
|
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
|
||||||
|
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
border-color: rgba(16, 185, 129, 0.55);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px -10px rgba(0, 0, 0, 0.15),
|
||||||
|
0 0 12px rgba(16, 185, 129, 0.25),
|
||||||
|
inset 0 1px 1px 0 rgba(255, 255, 255, 0.75),
|
||||||
|
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px -10px rgba(0, 0, 0, 0.15),
|
||||||
|
inset 0 1px 1px 0 rgba(255, 255, 255, 0.7),
|
||||||
|
inset 0 -1px 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.containerLoading {
|
.containerLoading {
|
||||||
border-color: rgba(16, 185, 129, 0.45);
|
animation: borderPulse 2s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flexWrapper {
|
.flexWrapper {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { CommitPanel } from "./editor/CommitPanel";
|
|||||||
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
|
import { CommitHistoryPanel } from "./editor/CommitHistoryPanel";
|
||||||
import { UndoListPanel } from "./editor/UndoListPanel";
|
import { UndoListPanel } from "./editor/UndoListPanel";
|
||||||
import { SubmitModal } from "./editor/SubmitModal";
|
import { SubmitModal } from "./editor/SubmitModal";
|
||||||
|
import ImageOverlayPanel from "./editor/ImageOverlayPanel";
|
||||||
|
import type { MapImageOverlay } from "@/uhm/components/map/imageOverlay";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: EditorMode;
|
mode: EditorMode;
|
||||||
@@ -38,6 +40,13 @@ type Props = {
|
|||||||
changesCount: number;
|
changesCount: number;
|
||||||
undoStack: UndoAction[];
|
undoStack: UndoAction[];
|
||||||
width?: number;
|
width?: number;
|
||||||
|
imageOverlay: MapImageOverlay | null;
|
||||||
|
onPickImageOverlay: (file: File | null) => void;
|
||||||
|
onPasteImageOverlay: () => void;
|
||||||
|
imageOverlayKeyboardEnabled: boolean;
|
||||||
|
onImageOverlayKeyboardEnabledChange: (enabled: boolean) => void;
|
||||||
|
onImageOverlayOpacityChange: (opacity: number) => void;
|
||||||
|
onRemoveImageOverlay: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
@@ -61,7 +70,14 @@ export default function Editor({
|
|||||||
commits,
|
commits,
|
||||||
changesCount,
|
changesCount,
|
||||||
undoStack,
|
undoStack,
|
||||||
width = 280,
|
width = 350,
|
||||||
|
imageOverlay,
|
||||||
|
onPickImageOverlay,
|
||||||
|
onPasteImageOverlay,
|
||||||
|
imageOverlayKeyboardEnabled,
|
||||||
|
onImageOverlayKeyboardEnabledChange,
|
||||||
|
onImageOverlayOpacityChange,
|
||||||
|
onRemoveImageOverlay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// State đóng/mở modal submit project.
|
// State đóng/mở modal submit project.
|
||||||
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
|
const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false);
|
||||||
@@ -107,6 +123,18 @@ export default function Editor({
|
|||||||
latestCommitLabel={latestCommitLabel}
|
latestCommitLabel={latestCommitLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<ImageOverlayPanel
|
||||||
|
overlay={imageOverlay}
|
||||||
|
onPickImage={onPickImageOverlay}
|
||||||
|
onPasteImage={onPasteImageOverlay}
|
||||||
|
keyboardEnabled={imageOverlayKeyboardEnabled}
|
||||||
|
onKeyboardEnabledChange={onImageOverlayKeyboardEnabledChange}
|
||||||
|
onOpacityChange={onImageOverlayOpacityChange}
|
||||||
|
onRemove={onRemoveImageOverlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToolsPanel
|
<ToolsPanel
|
||||||
mode={mode}
|
mode={mode}
|
||||||
setMode={setMode}
|
setMode={setMode}
|
||||||
|
|||||||
+237
-238
@@ -311,249 +311,248 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "fit-content",
|
|
||||||
maxWidth: "95%",
|
|
||||||
margin: "0 auto",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "10px",
|
|
||||||
background: "rgba(15, 23, 42, 0.88)",
|
|
||||||
border: "1px solid rgba(148, 163, 184, 0.38)",
|
|
||||||
borderRadius: "999px",
|
|
||||||
padding: "8px 12px",
|
|
||||||
color: "#e2e8f0",
|
|
||||||
backdropFilter: "blur(3px)",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
title={
|
|
||||||
isGlobeProjection
|
|
||||||
? "Dang o che do hinh cau (globe)"
|
|
||||||
: "Dang o che do trai phang (flat)"
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "0 6px",
|
|
||||||
userSelect: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isGlobeProjection}
|
|
||||||
onChange={(e) => setIsGlobeProjection(e.target.checked)}
|
|
||||||
aria-label="Toggle globe projection"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: "42px",
|
|
||||||
height: "22px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
|
||||||
background: isGlobeProjection
|
|
||||||
? "rgba(56, 189, 248, 0.30)"
|
|
||||||
: "rgba(148, 163, 184, 0.18)",
|
|
||||||
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
|
|
||||||
transition: "background 160ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "2px",
|
|
||||||
left: isGlobeProjection ? "22px" : "2px",
|
|
||||||
width: "18px",
|
|
||||||
height: "18px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
|
|
||||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
|
|
||||||
transition: "left 160ms ease, background 160ms ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
|
|
||||||
fontWeight: 700,
|
|
||||||
minWidth: "40px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isGlobeProjection ? "Globe" : "Flat"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{onViewModeChange ? (
|
|
||||||
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px", flexShrink: 0 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onViewModeChange("local")}
|
|
||||||
style={{
|
|
||||||
padding: "4px 10px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: 700,
|
|
||||||
background: viewMode === "local" ? "#2563eb" : "transparent",
|
|
||||||
color: viewMode === "local" ? "white" : "#94a3b8",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "background 150ms, color 150ms",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
LOCAL
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onViewModeChange("global")}
|
|
||||||
style={{
|
|
||||||
padding: "4px 10px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
fontSize: "12px",
|
|
||||||
fontWeight: 700,
|
|
||||||
background: viewMode === "global" ? "#2563eb" : "transparent",
|
|
||||||
color: viewMode === "global" ? "white" : "#94a3b8",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "background 150ms, color 150ms",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
GLOBAL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{onEnterPreview || onExitPreview ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
|
|
||||||
style={{
|
|
||||||
...zoomButtonStyle,
|
|
||||||
width: "auto",
|
|
||||||
minWidth: "76px",
|
|
||||||
padding: "0 12px",
|
|
||||||
background: isPreviewMode ? "#334155" : "#166534",
|
|
||||||
fontWeight: 800,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
|
|
||||||
title={isPreviewMode ? "Thoat preview" : "Xem nhu nguoi dung"}
|
|
||||||
>
|
|
||||||
{isPreviewMode ? "Editor" : "Preview"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleZoomByStep(-0.8)}
|
|
||||||
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
|
||||||
aria-label="Zoom out"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={zoomBounds.min}
|
|
||||||
max={zoomBounds.max}
|
|
||||||
step={0.1}
|
|
||||||
value={zoomLevel}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
try {
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
} catch {
|
|
||||||
// Browser may reject capture for non-primary pointers; drag lock still works.
|
|
||||||
}
|
|
||||||
beginZoomSliderDrag();
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
try {
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
||||||
} catch {
|
|
||||||
// Ignore if capture was already released.
|
|
||||||
}
|
|
||||||
endZoomSliderDrag();
|
|
||||||
}}
|
|
||||||
onPointerCancel={endZoomSliderDrag}
|
|
||||||
onBlur={endZoomSliderDrag}
|
|
||||||
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: "60px",
|
|
||||||
accentColor: "#38bdf8",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
aria-label="Map zoom"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleZoomByStep(0.8)}
|
|
||||||
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
|
||||||
aria-label="Zoom in"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minWidth: "56px",
|
width: "fit-content",
|
||||||
textAlign: "right",
|
maxWidth: "95%",
|
||||||
fontSize: "12px",
|
margin: "0 auto",
|
||||||
color: "#cbd5e1",
|
display: "flex",
|
||||||
fontVariantNumeric: "tabular-nums",
|
alignItems: "center",
|
||||||
flexShrink: 0,
|
gap: "10px",
|
||||||
|
background: "rgba(15, 23, 42, 0.88)",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.38)",
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
backdropFilter: "blur(3px)",
|
||||||
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{zoomLevel.toFixed(1)}x
|
<label
|
||||||
</div>
|
title={
|
||||||
|
isGlobeProjection
|
||||||
|
? "Dang o che do hinh cau (globe)"
|
||||||
|
: "Dang o che do trai phang (flat)"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "0 6px",
|
||||||
|
userSelect: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isGlobeProjection}
|
||||||
|
onChange={(e) => setIsGlobeProjection(e.target.checked)}
|
||||||
|
aria-label="Toggle globe projection"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "42px",
|
||||||
|
height: "22px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||||
|
background: isGlobeProjection
|
||||||
|
? "rgba(56, 189, 248, 0.30)"
|
||||||
|
: "rgba(148, 163, 184, 0.18)",
|
||||||
|
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
|
||||||
|
transition: "background 160ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "2px",
|
||||||
|
left: isGlobeProjection ? "22px" : "2px",
|
||||||
|
width: "18px",
|
||||||
|
height: "18px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
|
||||||
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
|
||||||
|
transition: "left 160ms ease, background 160ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
|
||||||
|
fontWeight: 700,
|
||||||
|
minWidth: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isGlobeProjection ? "Globe" : "Flat"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{onPlayPreviewReplay ? (
|
{onViewModeChange ? (
|
||||||
<button
|
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px", flexShrink: 0 }}>
|
||||||
type="button"
|
<button
|
||||||
onClick={onPlayPreviewReplay}
|
type="button"
|
||||||
style={{
|
onClick={() => onViewModeChange("local")}
|
||||||
...zoomButtonStyle,
|
style={{
|
||||||
width: "auto",
|
padding: "4px 10px",
|
||||||
minWidth: "64px",
|
borderRadius: "999px",
|
||||||
padding: "0 12px",
|
fontSize: "12px",
|
||||||
display: "inline-flex",
|
fontWeight: 700,
|
||||||
alignItems: "center",
|
background: viewMode === "local" ? "#2563eb" : "transparent",
|
||||||
justifyContent: "center",
|
color: viewMode === "local" ? "white" : "#94a3b8",
|
||||||
gap: "7px",
|
border: "none",
|
||||||
background: "#2563eb",
|
cursor: "pointer",
|
||||||
fontSize: "13px",
|
transition: "background 150ms, color 150ms",
|
||||||
fontWeight: 800,
|
}}
|
||||||
flexShrink: 0,
|
>
|
||||||
}}
|
LOCAL
|
||||||
aria-label="Play selected replay"
|
</button>
|
||||||
title="Play replay của geometry đang chọn"
|
<button
|
||||||
>
|
type="button"
|
||||||
<span
|
onClick={() => onViewModeChange("global")}
|
||||||
aria-hidden="true"
|
style={{
|
||||||
style={{
|
padding: "4px 10px",
|
||||||
width: 0,
|
borderRadius: "999px",
|
||||||
height: 0,
|
fontSize: "12px",
|
||||||
borderTop: "5px solid transparent",
|
fontWeight: 700,
|
||||||
borderBottom: "5px solid transparent",
|
background: viewMode === "global" ? "#2563eb" : "transparent",
|
||||||
borderLeft: "8px solid currentColor",
|
color: viewMode === "global" ? "white" : "#94a3b8",
|
||||||
}}
|
border: "none",
|
||||||
/>
|
cursor: "pointer",
|
||||||
Play
|
transition: "background 150ms, color 150ms",
|
||||||
</button>
|
}}
|
||||||
) : null}
|
>
|
||||||
</div>
|
GLOBAL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onEnterPreview || onExitPreview ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
|
||||||
|
style={{
|
||||||
|
...zoomButtonStyle,
|
||||||
|
width: "auto",
|
||||||
|
minWidth: "76px",
|
||||||
|
padding: "0 12px",
|
||||||
|
background: isPreviewMode ? "#334155" : "#166534",
|
||||||
|
fontWeight: 800,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
|
||||||
|
title={isPreviewMode ? "Thoat preview" : "Xem nhu nguoi dung"}
|
||||||
|
>
|
||||||
|
{isPreviewMode ? "Editor" : "Preview"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onPlayPreviewReplay ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPlayPreviewReplay}
|
||||||
|
style={{
|
||||||
|
...zoomButtonStyle,
|
||||||
|
width: "auto",
|
||||||
|
minWidth: "64px",
|
||||||
|
padding: "0 12px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "7px",
|
||||||
|
background: "#2563eb",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 800,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-label="Play selected replay"
|
||||||
|
title="Play replay của geometry đang chọn"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderTop: "5px solid transparent",
|
||||||
|
borderBottom: "5px solid transparent",
|
||||||
|
borderLeft: "8px solid currentColor",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleZoomByStep(-0.8)}
|
||||||
|
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={zoomBounds.min}
|
||||||
|
max={zoomBounds.max}
|
||||||
|
step={0.1}
|
||||||
|
value={zoomLevel}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Browser may reject capture for non-primary pointers; drag lock still works.
|
||||||
|
}
|
||||||
|
beginZoomSliderDrag();
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Ignore if capture was already released.
|
||||||
|
}
|
||||||
|
endZoomSliderDrag();
|
||||||
|
}}
|
||||||
|
onPointerCancel={endZoomSliderDrag}
|
||||||
|
onBlur={endZoomSliderDrag}
|
||||||
|
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: "60px",
|
||||||
|
accentColor: "#38bdf8",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
aria-label="Map zoom"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleZoomByStep(0.8)}
|
||||||
|
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: "56px",
|
||||||
|
textAlign: "right",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{zoomLevel.toFixed(1)}x
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ export function useMapInstance() {
|
|||||||
style: getBaseMapStyle(),
|
style: getBaseMapStyle(),
|
||||||
center: [0, 20],
|
center: [0, 20],
|
||||||
zoom: 2,
|
zoom: 2,
|
||||||
maxTileCacheSize: 150,
|
|
||||||
fadeDuration: 0,
|
|
||||||
collectResourceTiming: false,
|
|
||||||
crossSourceCollisions: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|||||||
@@ -82,12 +82,6 @@ export default function TimelineBar({
|
|||||||
}
|
}
|
||||||
}, [lower, upper, commitYearChange]);
|
}, [lower, upper, commitYearChange]);
|
||||||
|
|
||||||
const handleDragYearChange = useCallback((nextVal: number) => {
|
|
||||||
const clamped = clampYearValue(Math.trunc(nextVal), lower, upper);
|
|
||||||
localYearRef.current = clamped;
|
|
||||||
setLocalYear(clamped);
|
|
||||||
}, [lower, upper]);
|
|
||||||
|
|
||||||
const finishLocalYearChange = useCallback(() => {
|
const finishLocalYearChange = useCallback(() => {
|
||||||
commitYearChange(localYearRef.current);
|
commitYearChange(localYearRef.current);
|
||||||
setLocalYear(null);
|
setLocalYear(null);
|
||||||
@@ -169,14 +163,23 @@ export default function TimelineBar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<CanvasTimelineRuler
|
<span className={styles.labelBounds}>{formatYear(lower)}</span>
|
||||||
year={displayYear}
|
<input
|
||||||
onYearChange={handleDragYearChange}
|
type="range"
|
||||||
onYearCommit={finishLocalYearChange}
|
min={lower}
|
||||||
minYear={lower}
|
max={upper}
|
||||||
maxYear={upper}
|
step={1}
|
||||||
|
value={displayYear}
|
||||||
|
onChange={(event) => handleLocalYearChange(Number(event.target.value))}
|
||||||
|
onMouseUp={finishLocalYearChange}
|
||||||
|
onTouchEnd={finishLocalYearChange}
|
||||||
disabled={effectiveDisabled}
|
disabled={effectiveDisabled}
|
||||||
|
className={styles.slider}
|
||||||
|
aria-label="Timeline year"
|
||||||
/>
|
/>
|
||||||
|
<span className={styles.labelBoundsRight}>
|
||||||
|
{formatYear(upper)}
|
||||||
|
</span>
|
||||||
<div className={styles.numberWrapper}>
|
<div className={styles.numberWrapper}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -256,304 +259,3 @@ function formatYear(year: number): string {
|
|||||||
}
|
}
|
||||||
return `${year}`;
|
return `${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasRulerProps {
|
|
||||||
year: number;
|
|
||||||
onYearChange: (year: number) => void;
|
|
||||||
onYearCommit: () => void;
|
|
||||||
minYear: number;
|
|
||||||
maxYear: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CanvasTimelineRuler({
|
|
||||||
year,
|
|
||||||
onYearChange,
|
|
||||||
onYearCommit,
|
|
||||||
minYear,
|
|
||||||
maxYear,
|
|
||||||
disabled = false,
|
|
||||||
}: CanvasRulerProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
// Visible span (in years)
|
|
||||||
const [span, setSpan] = useState(400); // default show 400 years
|
|
||||||
|
|
||||||
// Dimensions
|
|
||||||
const [dimensions, setDimensions] = useState({ width: 0, height: 48 });
|
|
||||||
|
|
||||||
// Dragging state
|
|
||||||
const dragRef = useRef<{
|
|
||||||
isDragging: boolean;
|
|
||||||
startX: number;
|
|
||||||
startYear: number;
|
|
||||||
hasDragged: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Sync dimensions using ResizeObserver
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
|
||||||
if (!entries || !entries[0]) return;
|
|
||||||
const { width, height } = entries[0].contentRect;
|
|
||||||
setDimensions({ width, height: height || 48 });
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(container);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Draw the ruler on canvas
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas || dimensions.width === 0) return;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
// Support High DPI / Retina screens
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = dimensions.width * dpr;
|
|
||||||
canvas.height = dimensions.height * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
|
|
||||||
const width = dimensions.width;
|
|
||||||
const height = dimensions.height;
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
|
|
||||||
// Center year is the selected year
|
|
||||||
const centerYear = year;
|
|
||||||
const startYear = centerYear - span / 2;
|
|
||||||
const endYear = centerYear + span / 2;
|
|
||||||
|
|
||||||
const yearToX = (y: number) => {
|
|
||||||
return ((y - startYear) / span) * width;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine tick step based on span
|
|
||||||
let majorStep = 100;
|
|
||||||
let mediumStep = 10;
|
|
||||||
let minorStep = 1;
|
|
||||||
|
|
||||||
if (span > 3000) {
|
|
||||||
majorStep = 1000;
|
|
||||||
mediumStep = 100;
|
|
||||||
minorStep = 10;
|
|
||||||
} else if (span > 1500) {
|
|
||||||
majorStep = 500;
|
|
||||||
mediumStep = 50;
|
|
||||||
minorStep = 10;
|
|
||||||
} else if (span > 600) {
|
|
||||||
majorStep = 100;
|
|
||||||
mediumStep = 20;
|
|
||||||
minorStep = 5;
|
|
||||||
} else if (span > 200) {
|
|
||||||
majorStep = 100;
|
|
||||||
mediumStep = 10;
|
|
||||||
minorStep = 1;
|
|
||||||
} else if (span > 60) {
|
|
||||||
majorStep = 50;
|
|
||||||
mediumStep = 10;
|
|
||||||
minorStep = 1;
|
|
||||||
} else {
|
|
||||||
majorStep = 10;
|
|
||||||
mediumStep = 5;
|
|
||||||
minorStep = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ticks drawing bounds
|
|
||||||
const firstMajor = Math.floor(startYear / majorStep) * majorStep;
|
|
||||||
const lastMajor = Math.ceil(endYear / majorStep) * majorStep;
|
|
||||||
|
|
||||||
const pixelsPerYear = width / span;
|
|
||||||
const showMinor = pixelsPerYear * minorStep >= 3;
|
|
||||||
const showMedium = pixelsPerYear * mediumStep >= 5;
|
|
||||||
|
|
||||||
// Draw ruler track baseline
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, height - 8);
|
|
||||||
ctx.lineTo(width, height - 8);
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// 1. Draw minor & medium ticks
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let y = Math.floor(startYear); y <= Math.ceil(endYear); y++) {
|
|
||||||
if (y < minYear || y > maxYear) continue;
|
|
||||||
|
|
||||||
const isMajor = y % majorStep === 0;
|
|
||||||
const isMedium = y % mediumStep === 0;
|
|
||||||
const isMinor = y % minorStep === 0;
|
|
||||||
|
|
||||||
if (isMajor) continue;
|
|
||||||
|
|
||||||
let tickHeight = 0;
|
|
||||||
if (isMedium && showMedium) {
|
|
||||||
tickHeight = 7;
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
|
|
||||||
} else if (isMinor && showMinor) {
|
|
||||||
tickHeight = 4;
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tickHeight > 0) {
|
|
||||||
const x = yearToX(y);
|
|
||||||
ctx.moveTo(x, height - 8);
|
|
||||||
ctx.lineTo(x, height - 8 - tickHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// 2. Draw major ticks and labels
|
|
||||||
ctx.fillStyle = "rgba(255, 255, 255, 0.75)";
|
|
||||||
ctx.font = "600 10px system-ui, -apple-system, sans-serif";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "top";
|
|
||||||
|
|
||||||
for (let y = firstMajor; y <= lastMajor; y += majorStep) {
|
|
||||||
if (y < minYear || y > maxYear) continue;
|
|
||||||
|
|
||||||
const x = yearToX(y);
|
|
||||||
|
|
||||||
// Draw tick line
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, height - 8);
|
|
||||||
ctx.lineTo(x, height - 20);
|
|
||||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.65)";
|
|
||||||
ctx.lineWidth = 1.25;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw label
|
|
||||||
const label = formatYear(y);
|
|
||||||
ctx.fillText(label, x, height - 33);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Draw needle indicator in the center
|
|
||||||
const needleX = width / 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(needleX, 0);
|
|
||||||
ctx.lineTo(needleX, height - 4);
|
|
||||||
ctx.strokeStyle = "#10b981";
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.shadowColor = "rgba(16, 185, 129, 0.6)";
|
|
||||||
ctx.shadowBlur = 6;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw needle head triangle
|
|
||||||
ctx.fillStyle = "#10b981";
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(needleX - 5, 0);
|
|
||||||
ctx.lineTo(needleX + 5, 0);
|
|
||||||
ctx.lineTo(needleX, 6);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
}, [year, span, dimensions, minYear, maxYear]);
|
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
|
||||||
if (disabled) return;
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const zoomFactor = e.deltaY > 0 ? 1.15 : 0.85;
|
|
||||||
const nextSpan = Math.max(10, Math.min(10000, span * zoomFactor));
|
|
||||||
setSpan(Math.round(nextSpan));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
||||||
if (disabled) return;
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
dragRef.current = {
|
|
||||||
isDragging: true,
|
|
||||||
startX: e.clientX,
|
|
||||||
startYear: year,
|
|
||||||
hasDragged: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
||||||
if (!dragRef.current || !dragRef.current.isDragging) return;
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const dx = e.clientX - dragRef.current.startX;
|
|
||||||
if (Math.abs(dx) > 3) {
|
|
||||||
dragRef.current.hasDragged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const yearsPerPixel = span / dimensions.width;
|
|
||||||
const deltaYears = -dx * yearsPerPixel;
|
|
||||||
const nextYear = clampYearValue(Math.round(dragRef.current.startYear + deltaYears), minYear, maxYear);
|
|
||||||
|
|
||||||
onYearChange(nextYear);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
||||||
if (!dragRef.current) return;
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const dragInfo = dragRef.current;
|
|
||||||
dragRef.current = null;
|
|
||||||
|
|
||||||
if (!dragInfo.hasDragged) {
|
|
||||||
// Click to jump
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (canvas) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const clickedX = e.clientX - rect.left;
|
|
||||||
const centerYear = year;
|
|
||||||
const startYear = centerYear - span / 2;
|
|
||||||
const clickedYear = clampYearValue(
|
|
||||||
Math.round(startYear + (clickedX / rect.width) * span),
|
|
||||||
minYear,
|
|
||||||
maxYear
|
|
||||||
);
|
|
||||||
onYearChange(clickedYear);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onYearCommit();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 44,
|
|
||||||
position: "relative",
|
|
||||||
background: "rgba(255, 255, 255, 0.04)",
|
|
||||||
borderRadius: 22,
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
||||||
overflow: "hidden",
|
|
||||||
cursor: disabled ? "not-allowed" : "ew-resize",
|
|
||||||
}}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export function createEditorStore(options: EditorStoreOptions): EditorStoreApi {
|
|||||||
geoSearchResults: [],
|
geoSearchResults: [],
|
||||||
isGeoSearching: false,
|
isGeoSearching: false,
|
||||||
requestedActiveWikiId: null,
|
requestedActiveWikiId: null,
|
||||||
leftPanelWidth: 280,
|
leftPanelWidth: 320,
|
||||||
rightPanelWidth: 420,
|
rightPanelWidth: 420,
|
||||||
timelineFilterEnabled: true,
|
timelineFilterEnabled: true,
|
||||||
geometryBindingFilterEnabled: true,
|
geometryBindingFilterEnabled: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user