From b5dcda83a97330a1c35dbe82ebb0ebbc0ecd3635 Mon Sep 17 00:00:00 2001 From: bokhonglo Date: Thu, 21 May 2026 18:00:15 +0700 Subject: [PATCH] feat: implement TimelineBar component and add MapLayers panel styling with collapse functionality --- src/app/MapLayers.module.css | 248 ++++++++++++++++ src/app/editor/[id]/page.tsx | 2 + src/app/page.tsx | 169 +++++++++-- src/uhm/components/map/useMapSync.ts | 21 +- src/uhm/components/ui/TimelineBar.module.css | 283 +++++++++++++++++++ src/uhm/components/ui/TimelineBar.tsx | 105 +------ 6 files changed, 710 insertions(+), 118 deletions(-) create mode 100644 src/app/MapLayers.module.css create mode 100644 src/uhm/components/ui/TimelineBar.module.css diff --git a/src/app/MapLayers.module.css b/src/app/MapLayers.module.css new file mode 100644 index 0000000..644d3b3 --- /dev/null +++ b/src/app/MapLayers.module.css @@ -0,0 +1,248 @@ +.panel { + position: absolute; + left: 16px; + top: 16px; + z-index: 20; + width: 280px; + max-width: calc(100vw - 2rem); + background: rgba(15, 23, 42, 0.88); + border-radius: 14px; + backdrop-filter: blur(10px); + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 12px 16px; + transition: border-bottom-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.headerCollapsed { + border-bottom-color: transparent; +} + +.collapseButton { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #f8fafc; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + padding: 0; + flex-shrink: 0; + margin-left: 12px; +} + +.collapseButton:hover { + background: rgba(255, 255, 255, 0.2); + color: #ffffff; +} + +.collapseIcon { + width: 14px; + height: 14px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.iconRotated { + transform: rotate(180deg); +} + +.title { + font-size: 13px; + font-weight: 700; + color: #f8fafc; +} + +.subtitle { + margin-top: 2px; + font-size: 11px; + color: #94a3b8; + font-weight: 600; +} + +.content { + display: grid; + gap: 12px; + padding: 12px 16px; + max-height: 1000px; + opacity: 1; + overflow: hidden; + transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease, gap 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.contentCollapsed { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + pointer-events: none; + gap: 0; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + font-weight: 700; +} + +.sectionTitle { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #94a3b8; + font-weight: 700; + margin-bottom: 6px; +} + +.actions { + display: flex; + gap: 8px; +} + +.actionButton { + background: transparent; + border: none; + font-size: 10px; + color: #94a3b8; + cursor: pointer; + font-weight: 700; + transition: color 0.15s; + padding: 0; +} + +.actionButton:hover { + color: #ffffff; +} + +.listContainer { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 250px; + overflow-y: auto; + padding-right: 4px; +} + +/* Custom scrollbar for premium look */ +.listContainer::-webkit-scrollbar { + width: 4px; +} + +.listContainer::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.02); + border-radius: 4px; +} + +.listContainer::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 4px; +} + +.listContainer::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.25); +} + +.listItem { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + text-align: left; + transition: all 0.15s ease; + color: #e2e8f0; +} + +.listItem:hover { + background: rgba(255, 255, 255, 0.08); +} + +.listItemLeft { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.itemHashIcon { + width: 12px; + height: 12px; + color: #94a3b8; + flex-shrink: 0; + transition: color 0.15s ease; +} + +.itemName { + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-transform: capitalize; + color: #fff; +} + +.eyeIcon { + width: 14px; + height: 14px; + color: #fff; + opacity: 0.7; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.listItem:hover .eyeIcon { + opacity: 1; +} + +/* Active status styling */ +.listItemActiveSky .itemHashIcon { + color: #38bdf8; +} + +.listItemActiveEmerald .itemHashIcon { + color: #34d399; +} + +/* Inactive status styling */ +.listItemInactive { + opacity: 0.55; +} + +.listItemInactive .itemName { + color: #94a3b8; +} + +.listItemInactive:hover { + opacity: 0.8; + background: rgba(255, 255, 255, 0.06); +} + +.eyeIconInactive { + width: 14px; + height: 14px; + color: #94a3b8; + opacity: 0.5; + flex-shrink: 0; +} \ No newline at end of file diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 8a7ed8e..1933a93 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -2252,6 +2252,8 @@ function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity ...feature, properties: { ...feature.properties, + entity_id: entityIds[0] || null, + entity_ids: entityIds, entity_name: candidates[0]?.name || null, entity_names: candidates.map((candidate) => candidate.name), entity_label_candidates: candidates, diff --git a/src/app/page.tsx b/src/app/page.tsx index 35500d4..27f6d97 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Map, { type MapHoverPayload } from "@/uhm/components/Map"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; +import mapLayersStyles from "./MapLayers.module.css"; import { fetchEntities, type Entity } from "@/uhm/api/entities"; import { fetchGeometriesByBBox } from "@/uhm/api/geometries"; import { ApiError } from "@/uhm/api/http"; @@ -80,6 +81,7 @@ export default function Page() { total: 0, }); const [hoverAnchor, setHoverAnchor] = useState(null); + const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false); const [activeEntityId, setActiveEntityId] = useState(null); const [activeWikiSlug, setActiveWikiSlug] = useState(null); const [wikiCache, setWikiCache] = useState>({}); @@ -553,26 +555,55 @@ export default function Page() { style={activeEntityId && isLargeScreen ? { right: `${sidebarWidth + 32}px` } : undefined} /> -
-
-
Map Layers
-
{helperText}
+
+
+
+
Map Layers
+
{helperText}
+
+
-
+
-
+
Background -
- -
-
+
{BACKGROUND_LAYER_OPTIONS.map((layer) => { const active = Boolean(backgroundVisibility[layer.id]); return ( @@ -580,12 +611,58 @@ export default function Page() { key={layer.id} type="button" onClick={() => handleToggleBackgroundLayer(layer.id)} - className={`rounded-md border px-2.5 py-1 text-xs transition ${active - ? "border-sky-400/40 bg-sky-500/10 text-sky-200" - : "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200" - }`} + className={`${mapLayersStyles.listItem} ${ + active ? mapLayersStyles.listItemActiveSky : mapLayersStyles.listItemInactive + }`} > - {layer.label} +
+ + + + + + + {layer.label} +
+ + {active ? ( + + + + + ) : ( + + + + + )} ); })} @@ -593,10 +670,10 @@ export default function Page() {
-
+
Geometry
-
+
{GEO_TYPE_KEYS.map((typeKey) => { const active = geometryVisibility[typeKey] !== false; return ( @@ -609,12 +686,60 @@ export default function Page() { [typeKey]: prev[typeKey] === false, })); }} - className={`rounded-md border px-2.5 py-1 text-xs capitalize transition ${active - ? "border-emerald-400/40 bg-emerald-500/10 text-emerald-200" - : "border-white/10 bg-white/[0.03] text-slate-400 hover:text-slate-200" - }`} + className={`${mapLayersStyles.listItem} ${ + active ? mapLayersStyles.listItemActiveEmerald : mapLayersStyles.listItemInactive + }`} > - {typeKey.replaceAll("_", " ")} +
+ + + + + + + + {typeKey.replaceAll("_", " ")} + +
+ + {active ? ( + + + + + ) : ( + + + + + )} ); })} diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index 075d76d..ee2f808 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -94,7 +94,12 @@ export function useMapSync({ fitBoundsAppliedRef.current = false; }, [fitBoundsKey]); - const applyDraftToMap = useCallback((fc: FeatureCollection) => { + const applyDraftToMap = useCallback(( + fc: FeatureCollection, + labelContextOverride?: FeatureCollection, + selectedIdsOverride?: (string | number)[], + highlightFeaturesOverride?: FeatureCollection | null + ) => { const map = mapRef.current; if (!map) return; @@ -110,11 +115,16 @@ export function useMapSync({ } } + const labelContext = labelContextOverride || labelContextDraftRef.current || fc; + const currentSelectedIds = selectedIdsOverride || selectedFeatureIdsRef.current; + const highlightFeaturesVal = highlightFeaturesOverride !== undefined + ? highlightFeaturesOverride + : highlightFeaturesRef.current; + const visibleDraftRaw = respectBindingFilterRef.current - ? filterDraftByBinding(fc, selectedFeatureIdsRef.current, highlightFeaturesRef.current) - : fc; + ? filterDraftByBinding(labelContext, currentSelectedIds, highlightFeaturesVal) + : labelContext; const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current); - const labelContext = labelContextDraftRef.current || fc; const labelTimelineYear = labelTimelineYearRef.current; const { polygons, points } = splitDraftFeatures(visibleDraft); const labeledGeometries = decorateLineFeaturesWithLabels(polygons, labelContext, labelTimelineYear); @@ -127,7 +137,6 @@ export function useMapSync({ polygonLabelSource.setData(polygonLabels); (map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes); - const currentSelectedIds = selectedFeatureIdsRef.current; currentSelectedIds.forEach((id) => { setSelectedFeatureState(map, id, true); }); @@ -197,7 +206,7 @@ export function useMapSync({ }, [imageOverlay, mapRef]); useEffect(() => { - applyDraftToMap(draft); + applyDraftToMap(draft, labelContextDraft, selectedFeatureIds, highlightFeatures); const editingId = editingEngineRef.current?.editingRef?.current?.id; if (allowGeometryEditing && editingId !== undefined && editingId !== null) { const stillExists = draft.features.some((f) => f.properties.id === editingId); diff --git a/src/uhm/components/ui/TimelineBar.module.css b/src/uhm/components/ui/TimelineBar.module.css new file mode 100644 index 0000000..37a0f05 --- /dev/null +++ b/src/uhm/components/ui/TimelineBar.module.css @@ -0,0 +1,283 @@ +.container { + position: absolute; + left: 18px; + right: 18px; + bottom: 16px; + z-index: 10; + background: linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 50px; + padding: 10px 16px; + color: #f8fafc; + box-shadow: + 0 10px 30px -10px rgba(0, 0, 0, 0.5), + inset 0 1px 1px 0 rgba(255, 255, 255, 0.05); + 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 { + animation: borderPulse 2s infinite ease-in-out; +} + +.flexWrapper { + display: flex; + align-items: center; + flex-wrap: wrap; + row-gap: 8px; + column-gap: 12px; + font-size: 12px; +} + +.labelBounds { + color: #94a3b8; + font-weight: 600; + min-width: 44px; + user-select: none; +} + +.labelBoundsRight { + composes: labelBounds; + text-align: right; +} + +/* Custom range slider styling */ +.slider { + -webkit-appearance: none; + appearance: none; + flex: 1; + min-width: 120px; + height: 24px; + background: transparent; + cursor: pointer; + outline: none; +} + +.slider::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.15); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); + transition: all 0.2s; +} + +.slider:hover::-webkit-slider-runnable-track { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.1); +} + +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + margin-top: -6px; + height: 18px; + width: 18px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%); + border: 1.5px solid #ffffff; + box-shadow: + 0 0 10px rgba(16, 185, 129, 0.4), + 0 3px 6px rgba(0, 0, 0, 0.15), + inset 0 1px 1px rgba(255, 255, 255, 0.4); + cursor: pointer; + transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease; +} + +.slider:hover::-webkit-slider-thumb { + transform: scale(1.2); + box-shadow: + 0 0 15px rgba(16, 185, 129, 0.6), + 0 5px 10px rgba(0, 0, 0, 0.18), + inset 0 1px 1px rgba(255, 255, 255, 0.5); +} + +.slider:active::-webkit-slider-thumb { + transform: scale(1.05); + box-shadow: + 0 0 8px rgba(16, 185, 129, 0.5), + 0 2px 4px rgba(0, 0, 0, 0.15); +} + +/* Firefox slider styling */ +.slider::-moz-range-track { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.15); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3); + transition: all 0.2s; +} + +.slider:hover::-moz-range-track { + background: rgba(255, 255, 255, 0.25); +} + +.slider::-moz-range-thumb { + height: 15px; + width: 15px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%); + border: 1.5px solid #ffffff; + box-shadow: + 0 0 10px rgba(16, 185, 129, 0.4), + 0 3px 6px rgba(0, 0, 0, 0.15), + inset 0 1px 1px rgba(255, 255, 255, 0.4); + cursor: pointer; + transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease; +} + +.slider:hover::-moz-range-thumb { + transform: scale(1.2); +} + +/* Custom inputs styling */ +.numberInput { + width: 128px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + font-size: 13px; + font-weight: 600; + outline: none; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(4px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.numberInput:hover { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.12); +} + +.numberInput:focus { + border-color: #10b981; + box-shadow: + 0 0 0 3px rgba(16, 185, 129, 0.25), + inset 0 1px 2px rgba(0, 0, 0, 0.2); + background: rgba(255, 255, 255, 0.15); +} + +.rangeInput { + composes: numberInput; + width: 84px; +} + +.rangeLabel { + display: inline-flex; + align-items: center; + gap: 8px; + color: #94a3b8; + font-weight: 600; + white-space: nowrap; + transition: color 0.2s; +} + +.rangeLabel:hover { + color: #ffffff; +} + +/* Custom switch toggle styling */ +.toggleContainer { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; + transition: opacity 0.2s; +} + +.toggleTrack { + width: 38px; + height: 20px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.1); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); + position: relative; + flex: 0 0 auto; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.toggleTrackActive { + background: rgba(16, 185, 129, 0.35); + border-color: rgba(16, 185, 129, 0.6); + box-shadow: + 0 0 8px rgba(16, 185, 129, 0.35), + inset 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.toggleThumb { + position: absolute; + top: 1.5px; + left: 2px; + width: 15px; + height: 15px; + border-radius: 50%; + background: #94a3b8; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.toggleThumbActive { + left: 19px; + background: #34d399; + box-shadow: + 0 0 10px rgba(52, 211, 153, 0.6), + 0 2px 4px rgba(0, 0, 0, 0.25); +} + +.toggleContainer:hover .toggleTrack { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.15); +} + +.toggleContainer:hover .toggleTrackActive { + border-color: rgba(16, 185, 129, 0.7); +} + +.disabled { + opacity: 0.5 !important; + cursor: not-allowed !important; +} + +.disabled .slider, +.disabled .numberInput, +.disabled .toggleContainer, +.disabled .toggleTrack { + cursor: not-allowed !important; + pointer-events: none !important; +} \ No newline at end of file diff --git a/src/uhm/components/ui/TimelineBar.tsx b/src/uhm/components/ui/TimelineBar.tsx index 4e5c359..779a5a9 100644 --- a/src/uhm/components/ui/TimelineBar.tsx +++ b/src/uhm/components/ui/TimelineBar.tsx @@ -1,6 +1,7 @@ "use client"; import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/utils/timeline"; +import styles from "./TimelineBar.module.css"; type Props = { year: number; @@ -48,68 +49,22 @@ export default function TimelineBar({ return (
-
+
{typeof filterEnabled === "boolean" && onFilterEnabledChange ? ( ) : null} - {formatYear(lower)} + {formatYear(lower)} handleYearChange(Number(event.target.value))} disabled={effectiveDisabled} + className={styles.slider} aria-label="Timeline year" - style={{ - flex: 1, - minWidth: "120px", - accentColor: "#22c55e", - cursor: effectiveDisabled ? "not-allowed" : "pointer", - opacity: effectiveDisabled ? 0.6 : 1, - }} /> - + {formatYear(upper)} handleYearChange(Number(event.target.value))} disabled={effectiveDisabled} + className={styles.numberInput} aria-label="Timeline exact year" - style={{ - width: "128px", - border: "1px solid rgba(148, 163, 184, 0.45)", - borderRadius: "6px", - padding: "6px 8px", - background: "rgba(15, 23, 42, 0.7)", - color: "#f8fafc", - fontSize: "13px", - outline: "none", - }} /> {typeof timeRange === "number" && onTimeRangeChange ? ( ) : null} @@ -209,3 +133,4 @@ function formatYear(year: number): string { } return `${year}`; } +