init: replay mode

This commit is contained in:
taDuc
2026-05-14 03:41:58 +07:00
parent c92aaafc33
commit 8fc9456a6a
41 changed files with 619 additions and 396 deletions
+392 -330
View File
@@ -97,9 +97,12 @@ export default function Page() {
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
const lastSelectedFeatureIdRef = useRef<string | null>(null);
const [replayFeatureId, setReplayFeatureId] = useState<string | number | null>(null);
const [hideOutside, setHideOutside] = useState(false);
const {
mode,
setMode,
setMode: internalSetMode,
initialData,
setInitialData,
isSaving,
@@ -409,6 +412,52 @@ export default function Page() {
restoreCommit,
} = sectionCommands;
const setMode = useCallback((m: EditorMode, featureId?: string | number) => {
if (m === "replay" && featureId) {
setReplayFeatureId(featureId);
} else if (m !== "replay") {
setReplayFeatureId(null);
setHideOutside(false);
}
internalSetMode(m);
}, [internalSetMode]);
const onSetMode = setMode;
const effectiveGeometryVisibility = useMemo(() => {
const visibility: Record<string, boolean> = { ...geometryVisibility };
if (mode === "replay" && replayFeatureId) {
// Ẩn chính geo được chọn làm replay
visibility[String(replayFeatureId)] = false;
if (hideOutside) {
// Tìm feature đang replay để lấy danh sách binding
const replayFeature = editor.draft.features.find(
(f) => String(f.properties.id) === String(replayFeatureId)
);
const boundIds = new Set<string>();
if (replayFeature?.properties?.binding) {
replayFeature.properties.binding.forEach((id: string) => boundIds.add(String(id)));
}
// Ẩn tất cả các geo không nằm trong binding
editor.draft.features.forEach((f) => {
const fid = String(f.properties.id);
if (fid !== String(replayFeatureId) && !boundIds.has(fid)) {
visibility[fid] = false;
}
});
}
}
return visibility;
}, [geometryVisibility, mode, replayFeatureId, hideOutside, editor.draft.features]);
const onToggleHideOutside = useCallback(() => {
setHideOutside((prev) => !prev);
}, []);
const openProject = useCallback(async () => {
if (!projectId) return;
try {
@@ -693,7 +742,7 @@ export default function Page() {
useEffect(() => {
if (!selectedFeatureIds || selectedFeatureIds.length === 0) return;
const stillExistIds = selectedFeatureIds.filter(id =>
const stillExistIds = selectedFeatureIds.filter(id =>
timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id))
);
if (stillExistIds.length !== selectedFeatureIds.length) {
@@ -964,7 +1013,7 @@ export default function Page() {
bindingPatches,
nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO"
);
// Assume selectedFeature (the first one) reflects the representative binding in UI
const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]);
const firstFeatureHas = firstFeaturePrevBindings.includes(id);
@@ -1223,36 +1272,40 @@ export default function Page() {
return (
<div style={{ display: "flex", minHeight: "100vh" }}>
<Editor
mode={mode}
setMode={setMode}
entityStatus={entityStatus}
onUndo={editor.undo}
onCommit={commitSection}
onSubmit={submitCurrentSection}
onRestoreCommit={restoreCommit}
isSaving={isSaving}
isSubmitting={isSubmitting}
sectionTitle={activeSection?.title || "Đang tải project"}
projectStatus={projectState?.status || "editing"}
commitTitle={commitTitle}
onCommitTitleChange={setCommitTitle}
commitCount={sectionCommits.length}
hasHeadCommit={Boolean(projectState?.head_commit_id)}
headCommitId={projectState?.head_commit_id || null}
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null}
commits={sectionCommits}
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
width={leftPanelWidth}
/>
{mode !== "replay" && (
<>
<Editor
mode={mode}
setMode={setMode}
entityStatus={entityStatus}
onUndo={editor.undo}
onCommit={commitSection}
onSubmit={submitCurrentSection}
onRestoreCommit={restoreCommit}
isSaving={isSaving}
isSubmitting={isSubmitting}
sectionTitle={activeSection?.title || "Đang tải project"}
projectStatus={projectState?.status || "editing"}
commitTitle={commitTitle}
onCommitTitleChange={setCommitTitle}
commitCount={sectionCommits.length}
hasHeadCommit={Boolean(projectState?.head_commit_id)}
headCommitId={projectState?.head_commit_id || null}
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null}
commits={sectionCommits}
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
width={leftPanelWidth}
/>
<ResizeHandle
title="Resize left panel"
onDrag={(deltaX) => {
setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520));
}}
/>
<ResizeHandle
title="Resize left panel"
onDrag={(deltaX) => {
setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520));
}}
/>
</>
)}
{blockedPendingSubmissionId ? (
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220", color: "white", padding: "24px" }}>
@@ -1301,6 +1354,7 @@ export default function Page() {
{isBackgroundVisibilityReady ? (
<Map
mode={mode}
onSetMode={setMode}
draft={timelineVisibleDraft}
labelContextDraft={editor.draft}
selectedFeatureIds={selectedFeatureIds}
@@ -1309,326 +1363,334 @@ export default function Page() {
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
geometryVisibility={effectiveGeometryVisibility}
respectBindingFilter={geometryBindingFilterEnabled}
focusFeatureCollection={geometryFocusRequest?.collection || null}
focusRequestKey={geometryFocusRequest?.key ?? null}
focusPadding={96}
hideOutside={hideOutside}
onToggleHideOutside={onToggleHideOutside}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)}
<TimelineBar
year={timelineDraftYear}
onYearChange={handleTimelineYearChange}
isLoading={false}
disabled={false}
statusText={null}
filterEnabled={timelineFilterEnabled}
onFilterEnabledChange={setTimelineFilterEnabled}
/>
{mode !== "replay" && (
<TimelineBar
year={timelineDraftYear}
onYearChange={handleTimelineYearChange}
isLoading={false}
disabled={false}
statusText={null}
filterEnabled={timelineFilterEnabled}
onFilterEnabledChange={setTimelineFilterEnabled}
/>
)}
</div>
) : blockedPendingSubmissionId ? null : (
// Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag.
<div style={{ flex: 1, minHeight: "100vh", background: "#0b1220" }} />
)}
<ResizeHandle
title="Resize right panel"
onDrag={(deltaX) => {
// dragging handle (between map and right panel): moving right increases right panel width
setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720));
}}
/>
{mode !== "replay" && (
<>
<ResizeHandle
title="Resize right panel"
onDrag={(deltaX) => {
// dragging handle (between map and right panel): moving right increases right panel width
setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720));
}}
/>
<BackgroundLayersPanel
visibility={backgroundVisibility}
onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
geometryVisibility={geometryVisibility}
onToggleGeometryType={(typeKey) => {
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
}}
width={rightPanelWidth}
topContent={
<div style={{ display: "grid", gap: "12px" }}>
<UnifiedSearchBar
kind={searchKind}
onKindChange={(next) => {
setSearchKind(next);
setSearchQuery("");
setSearchQueryDraft("");
}}
query={searchQuery}
onQueryChange={setSearchQuery}
onLocalQueryChange={setSearchQueryDraft}
/>
<BackgroundLayersPanel
visibility={backgroundVisibility}
onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
geometryVisibility={geometryVisibility}
onToggleGeometryType={(typeKey) => {
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
}}
width={rightPanelWidth}
topContent={
<div style={{ display: "grid", gap: "12px" }}>
<UnifiedSearchBar
kind={searchKind}
onKindChange={(next) => {
setSearchKind(next);
setSearchQuery("");
setSearchQueryDraft("");
}}
query={searchQuery}
onQueryChange={setSearchQuery}
onLocalQueryChange={setSearchQueryDraft}
/>
{searchKind === "entity" && searchQueryDraft.trim().length > 0 ? (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Entity Results</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
{isEntitySearchLoading ? "Searching…" : `${entitySearchResults.length} results`}
</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>
{entitySearchResults.slice(0, 8).map((e) => (
<div
key={e.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
{searchKind === "entity" && searchQueryDraft.trim().length > 0 ? (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Entity Results</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
{isEntitySearchLoading ? "Searching…" : `${entitySearchResults.length} results`}
</div>
<button
type="button"
onClick={() => handleAddEntityRefToProject(e)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
}}
title="Add entity ref to project snapshot"
>
Add
</button>
</div>
))}
{!isEntitySearchLoading && entitySearchResults.length === 0 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>
) : null}
</div>
</div>
) : null}
{searchKind === "wiki" && searchQueryDraft.trim().length > 0 ? (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Wiki Results</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
{isWikiSearching ? "Searching…" : `${wikiSearchResults.length} results`}
</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>
{wikiSearchResults.slice(0, 8).map((w) => (
<div
key={w.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{(w.title || "").trim() || "Untitled wiki"}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.id}
</div>
</div>
<button
type="button"
onClick={() => handleAddWikiRefToProject(w)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
}}
title="Add wiki ref to project snapshot"
>
Add
</button>
</div>
))}
{!isWikiSearching && wikiSearchResults.length === 0 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>
) : null}
</div>
</div>
) : null}
{searchKind === "geo" && searchQueryDraft.trim().length > 0 ? (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Geo Results</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
{isGeoSearching ? "Searching…" : `${geoSearchResults.length} entities`}
</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{geoSearchResults.slice(0, 6).map((item) => (
<div
key={item.entity_id}
style={{
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
display: "grid",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.name?.trim() || item.entity_id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.entity_id}
</div>
</div>
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
{Array.isArray(item.geometries) ? item.geometries.length : 0} geos
</div>
</div>
{item.description?.trim() ? (
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
{item.description.trim()}
</div>
) : null}
{Array.isArray(item.geometries) && item.geometries.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{item.geometries.map((geo) => (
<div
key={geo.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #243244",
background: "#0f172a",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
#{geo.id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"}${geo.time_end ?? "?"}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => handleImportGeoFromSearch(item, geo)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
flex: "0 0 auto",
}}
title="Import geometry into current editor draft"
>
Import
</button>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>
{entitySearchResults.slice(0, 8).map((e) => (
<div
key={e.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.name}
</div>
))}
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.id}
</div>
</div>
<button
type="button"
onClick={() => handleAddEntityRefToProject(e)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
}}
title="Add entity ref to project snapshot"
>
Add
</button>
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>
No geometry linked.
</div>
)}
))}
{!isEntitySearchLoading && entitySearchResults.length === 0 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>
) : null}
</div>
))}
{!isGeoSearching && geoSearchResults.length === 0 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>
) : null}
</div>
</div>
) : null}
{searchKind === "wiki" && searchQueryDraft.trim().length > 0 ? (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Wiki Results</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
{isWikiSearching ? "Searching…" : `${wikiSearchResults.length} results`}
</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 6 }}>
{wikiSearchResults.slice(0, 8).map((w) => (
<div
key={w.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{(w.title || "").trim() || "Untitled wiki"}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{w.id}
</div>
</div>
<button
type="button"
onClick={() => handleAddWikiRefToProject(w)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
}}
title="Add wiki ref to project snapshot"
>
Add
</button>
</div>
))}
{!isWikiSearching && wikiSearchResults.length === 0 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>
) : null}
</div>
</div>
) : null}
{searchKind === "geo" && searchQueryDraft.trim().length > 0 ? (
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Geo Results</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>
{isGeoSearching ? "Searching…" : `${geoSearchResults.length} entities`}
</div>
</div>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{geoSearchResults.slice(0, 6).map((item) => (
<div
key={item.entity_id}
style={{
padding: 8,
borderRadius: 6,
border: "1px solid #1f2937",
background: "transparent",
display: "grid",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.name?.trim() || item.entity_id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{item.entity_id}
</div>
</div>
<div style={{ fontSize: 12, color: "#94a3b8", flex: "0 0 auto" }}>
{Array.isArray(item.geometries) ? item.geometries.length : 0} geos
</div>
</div>
{item.description?.trim() ? (
<div style={{ color: "#cbd5e1", fontSize: 12, lineHeight: 1.35 }}>
{item.description.trim()}
</div>
) : null}
{Array.isArray(item.geometries) && item.geometries.length ? (
<div style={{ display: "grid", gap: 6, maxHeight: 200, overflowY: "auto", paddingRight: 4 }}>
{item.geometries.map((geo) => (
<div
key={geo.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: 8,
borderRadius: 6,
border: "1px solid #243244",
background: "#0f172a",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#e5e7eb", fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
#{geo.id}
</div>
<div style={{ color: "#94a3b8", fontSize: 11 }}>
type: {geo.type || "unknown"}{" "}
{geo.time_start != null || geo.time_end != null
? `| time: ${geo.time_start ?? "?"}${geo.time_end ?? "?"}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => handleImportGeoFromSearch(item, geo)}
style={{
border: "none",
background: "#111827",
color: "#93c5fd",
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
fontSize: 12,
fontWeight: 700,
flex: "0 0 auto",
}}
title="Import geometry into current editor draft"
>
Import
</button>
</div>
))}
</div>
) : (
<div style={{ fontSize: 12, color: "#94a3b8" }}>
No geometry linked.
</div>
)}
</div>
))}
{!isGeoSearching && geoSearchResults.length === 0 ? (
<div style={{ fontSize: 12, color: "#94a3b8" }}>No results.</div>
) : null}
</div>
</div>
) : null}
<GeometryBindingPanel
geometries={geometryChoices}
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
selectedGeometryBindingIds={selectedGeometryBindingIds}
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
onFocusGeometry={handleFocusGeometryFromBindingPanel}
statusText={geoBindingStatus}
bindingFilterEnabled={geometryBindingFilterEnabled}
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
/>
<ProjectEntityRefsPanel
entityRefs={snapshotEntitiesVisible}
entityForm={entityForm}
onEntityFormChange={handleEntityFormChange}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityOnly={handleCreateEntityOnly}
onUpdateEntity={handleUpdateEntityInProject}
entityFormStatus={entityFormStatus}
hasSelectedGeometry={Boolean(selectedFeature)}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
/>
<WikiSidebarPanel
projectId={projectId}
wikis={snapshotWikis}
setWikis={setSnapshotWikisUndoable}
autoOpen={autoOpenWiki}
requestedActiveId={requestedActiveWikiId}
/>
<EntityWikiBindingsPanel
entities={projectEntityChoices}
wikis={snapshotWikis}
links={snapshotEntityWikiLinks}
setLinks={setSnapshotEntityWikiLinksUndoable}
/>
{!wikiOnly && selectedFeature ? (
<SelectedGeometryPanel
selectedFeatures={selectedFeatures}
entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange}
isEntitySubmitting={isEntitySubmitting}
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
changeCount={editor.changeCount}
/>
) : null}
</div>
) : null}
<GeometryBindingPanel
geometries={geometryChoices}
selectedGeometryId={selectedFeature ? String(selectedFeature.properties.id) : null}
selectedGeometryBindingIds={selectedGeometryBindingIds}
onToggleBindGeometryForSelectedGeometry={handleToggleBindGeometryForSelectedGeometry}
onFocusGeometry={handleFocusGeometryFromBindingPanel}
statusText={geoBindingStatus}
bindingFilterEnabled={geometryBindingFilterEnabled}
onBindingFilterEnabledChange={setGeometryBindingFilterEnabled}
/>
<ProjectEntityRefsPanel
entityRefs={snapshotEntitiesVisible}
entityForm={entityForm}
onEntityFormChange={handleEntityFormChange}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityOnly={handleCreateEntityOnly}
onUpdateEntity={handleUpdateEntityInProject}
entityFormStatus={entityFormStatus}
hasSelectedGeometry={Boolean(selectedFeature)}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onToggleBindEntityForSelectedGeometry={handleToggleBindEntityForSelectedGeometry}
/>
<WikiSidebarPanel
projectId={projectId}
wikis={snapshotWikis}
setWikis={setSnapshotWikisUndoable}
autoOpen={autoOpenWiki}
requestedActiveId={requestedActiveWikiId}
/>
<EntityWikiBindingsPanel
entities={projectEntityChoices}
wikis={snapshotWikis}
links={snapshotEntityWikiLinks}
setLinks={setSnapshotEntityWikiLinksUndoable}
/>
{!wikiOnly && selectedFeature ? (
<SelectedGeometryPanel
selectedFeatures={selectedFeatures}
entityTypeOptions={GEOMETRY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange}
isEntitySubmitting={isEntitySubmitting}
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
changeCount={editor.changeCount}
/>
) : null}
</div>
}
/>
}
/>
</>
)}
</div>
);
}
+84 -1
View File
@@ -26,6 +26,7 @@ type MapProps = {
geometryVisibility?: Record<string, boolean>;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
onSetMode?: (mode: EditorMode, featureId?: string | number) => void;
labelContextDraft?: FeatureCollection;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void;
@@ -40,10 +41,13 @@ type MapProps = {
focusFeatureCollection?: FeatureCollection | null;
focusRequestKey?: string | number | null;
focusPadding?: number | import("maplibre-gl").PaddingOptions;
hideOutside?: boolean;
onToggleHideOutside?: () => void;
};
export default function Map({
mode,
onSetMode,
draft,
backgroundVisibility,
geometryVisibility,
@@ -63,10 +67,13 @@ export default function Map({
focusFeatureCollection = null,
focusRequestKey = null,
focusPadding,
hideOutside = false,
onToggleHideOutside,
}: MapProps) {
const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft);
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
const onSetModeRef = useRef(onSetMode);
const onHoverFeatureChangeRef = useRef<MapProps["onHoverFeatureChange"]>(onHoverFeatureChange);
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
@@ -75,6 +82,7 @@ export default function Map({
useEffect(() => { modeRef.current = mode; }, [mode]);
useEffect(() => { draftRef.current = draft; }, [draft]);
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]);
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]);
useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]);
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]);
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]);
@@ -106,6 +114,7 @@ export default function Map({
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef,
onDeleteRef,
onUpdateRef,
@@ -150,6 +159,14 @@ export default function Map({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapLoaded]);
useEffect(() => {
const map = mapRef.current;
if (map && isMapLoaded) {
// Trigger resize after a short delay to allow layout to settle
setTimeout(() => map.resize(), 100);
}
}, [mode, isMapLoaded]);
return (
<div style={{ width: "100%", height, position: "relative" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
@@ -198,7 +215,7 @@ export default function Map({
>
<div
style={{
maxWidth: "520px",
maxWidth: "650px",
margin: "0 auto",
display: "flex",
alignItems: "center",
@@ -212,6 +229,72 @@ export default function Map({
pointerEvents: "auto",
}}
>
{mode === "replay" && (
<>
<button
type="button"
onClick={() => onSetMode?.("select")}
style={{
...zoomButtonStyle,
width: "auto",
padding: "0 12px",
fontSize: "12px",
fontWeight: 700,
background: "#7f1d1d",
color: "white",
border: "1px solid #991b1b",
borderRadius: "999px",
cursor: "pointer",
marginRight: "4px",
}}
>
Thoát Replay Edit
</button>
<div
onClick={onToggleHideOutside}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
marginRight: "8px",
userSelect: "none",
}}
>
<span style={{ fontSize: "12px", fontWeight: 700, color: hideOutside ? "#fb7185" : "#94a3b8" }}>
Hide Outside
</span>
<div
style={{
width: "32px",
height: "18px",
borderRadius: "10px",
background: hideOutside ? "#e11d48" : "#334155",
position: "relative",
transition: "background 0.2s",
border: "1px solid rgba(255,255,255,0.1)",
}}
>
<div
style={{
position: "absolute",
top: "2px",
left: hideOutside ? "16px" : "2px",
width: "12px",
height: "12px",
borderRadius: "50%",
background: "white",
transition: "left 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
boxShadow: "0 1px 2px rgba(0,0,0,0.3)",
}}
/>
</div>
</div>
<div style={{ width: "1px", height: "20px", background: "rgba(148, 163, 184, 0.3)", marginRight: "4px" }} />
</>
)}
<label
title={
isGlobeProjection
+7
View File
@@ -36,5 +36,12 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
</div>
)
}
if (mode === "replay") {
return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Đang trong chế đ trình diễn diễn biến kịch bản.
</div>
)
}
return null;
}
+5
View File
@@ -156,8 +156,13 @@ export function filterDraftByGeometryVisibility(
return {
...fc,
features: fc.features.filter((feature) => {
const id = String(feature.properties.id);
// Kiểm tra ẩn theo ID cụ thể (ưu tiên cao nhất)
if (visibility[id] === false) return false;
const key = getFeatureSemanticType(feature);
if (!key) return true;
// Kiểm tra ẩn theo loại (semantic type)
return visibility[key] !== false;
}),
};
+5 -1
View File
@@ -26,6 +26,7 @@ type UseMapInteractionProps = {
allowGeometryEditing: boolean;
selectedFeatureIds: (string | number)[];
onSelectFeatureIdsRef: React.MutableRefObject<(ids: (string | number)[]) => void>;
onSetModeRef: React.MutableRefObject<((mode: EditorMode) => void) | undefined>;
onCreateRef: React.MutableRefObject<((feature: FeatureCollection["features"][number]) => void) | undefined>;
onDeleteRef: React.MutableRefObject<((id: string | number) => void) | undefined>;
onUpdateRef: React.MutableRefObject<((id: string | number, geometry: Geometry) => void) | undefined>;
@@ -40,6 +41,7 @@ export function useMapInteraction({
allowGeometryEditing,
selectedFeatureIds,
onSelectFeatureIdsRef,
onSetModeRef,
onCreateRef,
onDeleteRef,
onUpdateRef,
@@ -142,7 +144,8 @@ export function useMapInteraction({
editingEngineRef.current?.beginEditing((originalFeature || feature) as any);
}
: undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids)
(ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id)
);
const cleanupPoint = initPoint(
@@ -236,6 +239,7 @@ export function useMapInteraction({
engineBindingsRef.current = {
draw: drawingEngine,
select: selectEngine,
replay: selectEngine,
"add-line": lineEngine,
"add-path": pathEngine,
"add-circle": circleEngine,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
} from "@/uhm/lib/map/styles/style";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import { PATH_ARROW_ICON_ID, PATH_ARROW_SOURCE_ID, POLYGON_LABEL_SOURCE_ID } from "@/uhm/lib/map/constants";
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypes";
import { ensurePointGeotypeIcons, getAllGeotypeLabelLayers, getAllGeotypeLayers } from "@/uhm/lib/map/styles/geotypeLayers";
import {
applyBackgroundLayerVisibility,
buildTypeMatchExpression,
+2 -1
View File
@@ -7,7 +7,8 @@ export type EditorMode =
| "add-point"
| "add-line"
| "add-path"
| "add-circle";
| "add-circle"
| "replay";
export type TimelineRange = {
min: number;
+18 -5
View File
@@ -7,7 +7,8 @@ export function initSelect(
getMode: ModeGetter,
onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onSelectIds?: (ids: (string | number)[]) => void
onSelectIds?: (ids: (string | number)[]) => void,
onReplayEdit?: (id: string | number) => void
) {
const FEATURE_STATE_SOURCES = [
@@ -16,7 +17,7 @@ export function initSelect(
"path-arrow-shapes",
] as const;
const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit);
const hasContextActions = Boolean(onDelete || onEdit || onReplayEdit);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -54,7 +55,7 @@ export function initSelect(
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
@@ -74,11 +75,12 @@ export function initSelect(
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
// Mở menu thao tác khi click phải lên feature.
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) return;
e.preventDefault(); // block browser menu
if (getMode() === "replay") return;
const features = map.queryRenderedFeatures(e.point, {
layers: selectableLayers,
@@ -105,7 +107,7 @@ export function initSelect(
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
if (getMode() !== "select" && getMode() !== "replay") return;
const selectableLayers = getSelectableLayers();
if (!selectableLayers.length) {
map.getCanvas().style.cursor = "";
@@ -218,6 +220,17 @@ export function initSelect(
hasMenuItems = true;
}
if (
selectedCount === 1 &&
onReplayEdit
) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) {
menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId)));
hasMenuItems = true;
}
}
if (onDelete) {
menu.appendChild(
createItem(
@@ -1,36 +1,36 @@
import maplibregl from "maplibre-gl";
export const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
export { ensurePointGeotypeIcons } from "./pointStyle";
export { ensurePointGeotypeIcons } from "./shared/pointStyle";
import { getDefenseLineLayers } from "./defense_line";
import { getAttackRouteLayers } from "./attack_route";
import { getRetreatRouteLayers } from "./retreat_route";
import { getInvasionRouteLayers } from "./invasion_route";
import { getMigrationRouteLayers } from "./migration_route";
import { getRefugeeRouteLayers } from "./refugee_route";
import { getTradeRouteLayers } from "./trade_route";
import { getShippingRouteLayers } from "./shipping_route";
import { getCountryLayers } from "./country";
import { getStateLayers } from "./state";
import { getEmpireLayers } from "./empire";
import { getKingdomLayers } from "./kingdom";
import { getWarLayers } from "./war";
import { getBattleLayers } from "./battle";
import { getCivilizationLayers } from "./civilization";
import { getRebellionZoneLayers } from "./rebellion_zone";
import { getPersonDeathplaceLayers } from "./person_deathplace";
import { getPersonBirthplaceLayers } from "./person_birthplace";
import { getPersonActivityLayers } from "./person_activity";
import { getTempleLayers } from "./temple";
import { getCapitalLayers } from "./capital";
import { getCityLayers } from "./city";
import { getFortressLayers } from "./fortress";
import { getCastleLayers } from "./castle";
import { getRuinLayers } from "./ruin";
import { getPortLayers } from "./port";
import { getBridgeLayers } from "./bridge";
import { getLineLabelLayers } from "./lineLabels";
import { getPolygonLabelLayers } from "./polygonLabels";
import { getDefenseLineLayers } from "./geotypes/defense_line";
import { getAttackRouteLayers } from "./geotypes/attack_route";
import { getRetreatRouteLayers } from "./geotypes/retreat_route";
import { getInvasionRouteLayers } from "./geotypes/invasion_route";
import { getMigrationRouteLayers } from "./geotypes/migration_route";
import { getRefugeeRouteLayers } from "./geotypes/refugee_route";
import { getTradeRouteLayers } from "./geotypes/trade_route";
import { getShippingRouteLayers } from "./geotypes/shipping_route";
import { getCountryLayers } from "./geotypes/country";
import { getStateLayers } from "./geotypes/state";
import { getEmpireLayers } from "./geotypes/empire";
import { getKingdomLayers } from "./geotypes/kingdom";
import { getWarLayers } from "./geotypes/war";
import { getBattleLayers } from "./geotypes/battle";
import { getCivilizationLayers } from "./geotypes/civilization";
import { getRebellionZoneLayers } from "./geotypes/rebellion_zone";
import { getPersonDeathplaceLayers } from "./geotypes/person_deathplace";
import { getPersonBirthplaceLayers } from "./geotypes/person_birthplace";
import { getPersonActivityLayers } from "./geotypes/person_activity";
import { getTempleLayers } from "./geotypes/temple";
import { getCapitalLayers } from "./geotypes/capital";
import { getCityLayers } from "./geotypes/city";
import { getFortressLayers } from "./geotypes/fortress";
import { getCastleLayers } from "./geotypes/castle";
import { getRuinLayers } from "./geotypes/ruin";
import { getPortLayers } from "./geotypes/port";
import { getBridgeLayers } from "./geotypes/bridge";
import { getLineLabelLayers } from "./shared/lineLabels";
import { getPolygonLabelLayers } from "./shared/polygonLabels";
import { LayerSpecification } from "maplibre-gl";
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getAttackRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getBattleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getBridgeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCapitalLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCastleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getCityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getCivilizationLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getCountryLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getDefenseLineLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getEmpireLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getFortressLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getInvasionRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getKingdomLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getMigrationRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPersonActivityLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPersonBirthplaceLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPersonDeathplaceLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getPortLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getRebellionZoneLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getRefugeeRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getRetreatRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getRuinLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getShippingRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getStateLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPointGeotypeLayers } from "./pointStyle";
import { buildPointGeotypeLayers } from "../shared/pointStyle";
export function getTempleLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildLineGeotypeLayers } from "./styleBuilders";
import { buildLineGeotypeLayers } from "../shared/styleBuilders";
export function getTradeRouteLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pointSourceId;
+1 -1
View File
@@ -1,5 +1,5 @@
import { LayerSpecification } from "maplibre-gl";
import { buildPolygonGeotypeLayers } from "./styleBuilders";
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
export function getWarLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void pathArrowSourceId;
+48
View File
@@ -15,6 +15,54 @@ export type CommitSnapshot = {
geometry_entity: GeometryEntitySnapshot[];
wikis: WikiSnapshot[];
entity_wiki: EntityWikiLinkSnapshot[];
replays?: BattleReplay[];
};
// ---- Replay / Scripting System ----
export type UIFunctionName =
| "hide_timeline"
| "hide_layer_panel"
| "hide_wiki_panel"
| "hide_zoom_panel"
| "hide_all_UI"
| "open_wiki";
export type MapFunctionName =
| "zoom_to_lnglat"
| "zoom_scale"
| "zoom_geometries"
| "change_geometry_color"
| "change_geometries_color"
| "change_geometry_texture"
| "change_geometries_texture"
| "hide_geometries";
export type NarrativeFunctionName = "set_title" | "set_descriptions";
export type ReplayAction<T> = {
function_name: T;
params: any[];
};
export type ReplayStep = {
duration: number; // Trọng số thời gian của step trong 1 stage
use_UI_function: ReplayAction<UIFunctionName>[];
use_map_function: ReplayAction<MapFunctionName>[];
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
};
export type ReplayStage = {
id: number; // số đếm thứ tự từ 0
title?: string;
detail_time_start: string;
detail_time_stop: string;
steps: ReplayStep[];
};
export type BattleReplay = {
geometry_id: string; // geometry mà khi nhấn vào là có thể replay
detail: ReplayStage[];
};
// ---- GeoJSON / FeatureCollection ----