diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts index 6cfa2b7..9adb026 100644 --- a/src/uhm/components/map/mapUtils.ts +++ b/src/uhm/components/map/mapUtils.ts @@ -1244,12 +1244,19 @@ export function clampNumber(value: number, min: number, max: number): number { } export function hashStringToColor(str: string): string { - let hash = 0; + let hash = 5381; for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = (hash * 33) ^ str.charCodeAt(i); } - const hue = Math.abs(hash) % 360; - return `hsl(${hue}, 70%, 50%)`; + // Use Knuth's multiplicative hashing multiplier to scatter consecutive/close hash values + const scattered = Math.abs(hash * 2654435761); + const hue = scattered % 360; + + // Vary saturation and lightness slightly to increase color diversity and uniqueness + const saturation = 70 + (scattered % 20); // 70% to 90% + const lightness = 45 + ((scattered >> 5) % 15); // 45% to 60% + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } export function decorateFeaturesWithEntityColors(fc: FeatureCollection): FeatureCollection { diff --git a/src/uhm/components/preview/PreviewLayout.tsx b/src/uhm/components/preview/PreviewLayout.tsx index e87a710..36c2f6e 100644 --- a/src/uhm/components/preview/PreviewLayout.tsx +++ b/src/uhm/components/preview/PreviewLayout.tsx @@ -11,6 +11,7 @@ import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import RelatedEntityPopup from "./RelatedEntityPopup"; import PinnedWikiPopup from "./PinnedWikiPopup"; +import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils"; import { fetchWikiById, type Wiki } from "@/uhm/api/wikis"; import type { Entity } from "@/uhm/api/entities"; @@ -81,6 +82,7 @@ const PreviewLayout = forwardRef(({ autoplayMode = null, replayPreview, + mapHandleRef, previewRelations, previewActiveEntityId, setPreviewActiveEntityId, @@ -89,6 +91,7 @@ const PreviewLayout = forwardRef(({ setPreviewSidebarWidth, previewWikiCache, setPreviewWikiCache, + isLargeScreen, }: Props, ref) => { const isReplayPreviewMode = mode === "replay_preview"; @@ -466,7 +469,15 @@ const PreviewLayout = forwardRef(({ // Search and focus place const handleFocusPresentPlace = useCallback((place: PresentPlaceSelection) => { setFocusedPresentPlace(place); - }, []); + const map = mapHandleRef?.current?.getMap(); + if (map) { + const currentZoom = map.getZoom(); + map.flyTo({ + center: [place.lng, place.lat], + zoom: Math.max(currentZoom, 13.5), + }); + } + }, [mapHandleRef]); const clearPresentPlaceFocus = useCallback(() => { setFocusedPresentPlace(null); @@ -476,6 +487,23 @@ const PreviewLayout = forwardRef(({ setFocusedPresentPlace(null); setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1); + const map = mapHandleRef?.current?.getMap(); + if (map && payload.geometry?.draw_geometry) { + const fc: FeatureCollection = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + id: payload.geometry.id, + }, + geometry: payload.geometry.draw_geometry, + }, + ], + }; + fitMapToFeatureCollection(map, fc, 84, { duration: 1000 }); + } + const linkedEntityIds = previewRelations.geometryEntityIds[String(payload.geometry.id)] || []; if (linkedEntityIds.length === 1) { selectReplayPreviewEntity(linkedEntityIds[0], { @@ -484,12 +512,23 @@ const PreviewLayout = forwardRef(({ selectGeometry: false, }); } - }, [previewRelations.geometryEntityIds, selectReplayPreviewEntity, setPreviewEntityFocusToken]); + }, [mapHandleRef, previewRelations.geometryEntityIds, selectReplayPreviewEntity, setPreviewEntityFocusToken]); const effectiveGeometryVisibility = useMemo(() => { return geometryVisibility; }, [geometryVisibility]); + const computedTimelineStyle = useMemo(() => { + const rightMargin = (isReplayPreviewWikiSidebarOpen && isLargeScreen) + ? previewSidebarWidth + 32 + : 18; + return { + left: "88px", + right: `${rightMargin}px`, + transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + }; + }, [isReplayPreviewWikiSidebarOpen, isLargeScreen, previewSidebarWidth]); + // Popup PinnedWikiPopup rows @@ -583,29 +622,43 @@ const PreviewLayout = forwardRef(({ {previewPinnedWikiPopupAnchor && previewPinnedWikiPopupRows.length > 0 ? ( @@ -648,11 +701,7 @@ const PreviewLayout = forwardRef(({ statusText={null} filterEnabled={replayPreview.timelineFilterEnabled} onFilterEnabledChange={replayPreview.setTimelineFilterEnabled} - style={ - isReplayPreviewWikiSidebarOpen - ? { right: `${previewSidebarWidth + 32}px` } - : undefined - } + style={computedTimelineStyle} /> ) : null} diff --git a/src/uhm/doc/color_hashing.md b/src/uhm/doc/color_hashing.md new file mode 100644 index 0000000..617d932 --- /dev/null +++ b/src/uhm/doc/color_hashing.md @@ -0,0 +1,72 @@ +# Thuật toán Băm Màu sắc từ ID (Color Hashing Algorithm) + +Tài liệu này mô tả chi tiết giải thuật băm chuỗi định danh (ID) thành mã màu sắc HSL trong ứng dụng bản đồ lịch sử, nhằm giải quyết vấn đề trùng lặp màu sắc hiển thị giữa các thực thể/hình học. + +--- + +## 1. Vấn đề thực tế (Problem Statement) +Trong các phiên bản trước, hàm băm chuỗi thành màu sử dụng giải thuật cộng dồn mã ký tự đơn giản: +$$\text{hash} = \sum \text{char} + ((\text{hash} \ll 5) - \text{hash})$$ +Với độ bão hòa (Saturation) và độ sáng (Lightness) cố định ở mức `70%` và `50%`. + +Cách tiếp cận này gặp phải điểm yếu nghiêm trọng khi xử lý **định danh tuần tự** (sequential IDs) hoặc các chuỗi ID gần giống nhau (ví dụ: các ID tự tăng như `1`, `2`, `3` hoặc các UUID chỉ khác nhau ký tự cuối): +* Giải thuật băm cũ sinh ra các giá trị băm liên tiếp nhau (ví dụ: `1001`, `1002`, `1003`). +* Khi chia lấy dư cho $360$ để tìm góc màu Hue, kết quả cho ra các góc màu liền kề (ví dụ: $201^\circ$, $202^\circ$, $203^\circ$). +* Đối với mắt người, các góc màu quá sát nhau này hoàn toàn không thể phân biệt được, dẫn đến việc các quốc gia/vùng lãnh thổ/tuyến đường cạnh nhau bị hiển thị trùng một màu, gây hiểu nhầm dữ liệu lịch sử. + +--- + +## 2. Giải pháp & Thuật toán Nâng cấp (Proposed Solution) + +Để giải quyết triệt để vấn đề này, thuật toán mới đã được cải tiến thông qua hai kỹ thuật chính: + +### A. Phân tán giá trị băm của Knuth (Knuth's Multiplicative Hashing) +Sau bước băm ký tự ban đầu bằng DJB2 nâng cao (sử dụng XOR), giá trị băm sẽ được nhân với hằng số vàng của Knuth: +$$A = 2654435761 \quad (\approx 2^{32} \times \frac{\sqrt{5} - 1}{2})$$ +Hằng số này hoạt động như một bộ xáo trộn bit (bit mixer). Hai giá trị băm ban đầu đứng cạnh nhau sau khi nhân với $2654435761$ và lấy trị tuyệt đối sẽ được phân tán đều khắp không gian số nguyên 32-bit. Điều này đảm bảo góc màu Hue giữa hai ID kề nhau sẽ có độ tương phản cực kỳ cao (ví dụ: góc màu lệch nhau từ $30^\circ$ tới $180^\circ$). + +### B. Biến thiên Độ bão hòa (Saturation) và Độ sáng (Lightness) +Thay vì cố định cứng $S = 70\%$ và $L = 50\%$, hai tham số này cũng được tính toán động từ giá trị băm phân tán: +* **Saturation ($S$):** Dao động ngẫu nhiên trong khoảng $[70\%, 90\%]$. +* **Lightness ($L$):** Dao động ngẫu nhiên trong khoảng $[45\%, 60\%]$. + +Điều này giúp mở rộng không gian màu từ 1 chiều (chỉ thay đổi Hue) lên 3 chiều (thay đổi cả Hue, Saturation và Lightness), tạo ra hàng ngàn biến thể màu sắc độc nhất. + +--- + +## 3. Mã Nguồn Triển khai (Implementation Code) + +Hàm băm được đặt tại [mapUtils.ts](../components/map/mapUtils.ts): + +```typescript +export function hashStringToColor(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + // Sử dụng hằng số nhân của Knuth để phân tán các mã băm kề nhau + const scattered = Math.abs(hash * 2654435761); + const hue = scattered % 360; + + // Tự động biến thiên nhẹ độ bão hòa và độ sáng để tăng độ đa dạng màu + const saturation = 70 + (scattered % 20); // 70% đến 90% + const lightness = 45 + ((scattered >> 5) % 15); // 45% đến 60% + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +} +``` + +--- + +## 4. Ứng dụng trong Bản đồ (Map Application Context) + +Hàm này được gọi tự động trong bộ lọc dữ liệu địa lý nhằm gán màu sắc trực quan cho các hình học không có màu chỉ định sẵn: +* **Tuyến đường (Lines):** Gộp các `entity_ids` thành một chuỗi duy nhất, sắp xếp theo thứ tự bảng chữ cái để đảm bảo tính nhất quán, sau đó băm thành màu sắc của tuyến đường. +* **Lãnh thổ/Vùng (Polygons):** Băm trực tiếp từ `geometry_id` của bản vẽ nháp hoặc thực thể để mỗi quốc gia/lãnh thổ có một màu sắc ranh giới trực quan riêng biệt. + +--- + +## 5. Ưu điểm nổi bật (Key Benefits) +1. **Độ tương phản cao (High Contrast):** Các thực thể có ID tuần tự nằm cạnh nhau trên bản đồ luôn hiển thị màu sắc tương phản rõ rệt. +2. **Nhất quán (Deterministic):** Cùng một ID chuỗi đầu vào sẽ luôn trả về chính xác một mã màu duy nhất ở mọi thời điểm tải trang. +3. **Thẩm mỹ hiện đại (Modern Aesthetics):** Giới hạn độ sáng trong khoảng $45\% - 60\%$ giúp giữ cho màu sắc luôn rực rỡ (neon-like), không bị quá tối ẩn vào nền bản đồ, cũng không bị quá sáng làm mất đi tính thẩm mỹ của giao diện tối (dark theme). diff --git a/src/uhm/lib/map/styles/geotypes/location.ts b/src/uhm/lib/map/styles/geotypes/location.ts index 6781f6a..4f2077a 100644 --- a/src/uhm/lib/map/styles/geotypes/location.ts +++ b/src/uhm/lib/map/styles/geotypes/location.ts @@ -23,6 +23,7 @@ export function getLocationLayers(sourceId: string, pathArrowSourceId?: string, type: "circle", source: pointSourceId!, filter: filter, + minzoom: 5, paint: { "circle-color": "#e2e8f0", "circle-radius": ["case", SELECTED_EXPR, 18, 0], @@ -38,6 +39,7 @@ export function getLocationLayers(sourceId: string, pathArrowSourceId?: string, type: "circle", source: pointSourceId!, filter: filter, + minzoom: 5, paint: { "circle-color": [ "case", @@ -67,6 +69,7 @@ export function getLocationLayers(sourceId: string, pathArrowSourceId?: string, type: "symbol", source: pointSourceId!, filter: filter, + minzoom: 5, layout: { "text-font": [...MAP_EMPHASIS_TEXT_FONT_STACK], "text-field": ["coalesce", ["get", "point_label"], ""], diff --git a/src/uhm/lib/map/styles/geotypes/region.ts b/src/uhm/lib/map/styles/geotypes/region.ts index baa33a9..ab47710 100644 --- a/src/uhm/lib/map/styles/geotypes/region.ts +++ b/src/uhm/lib/map/styles/geotypes/region.ts @@ -24,6 +24,7 @@ export function getRegionLayers(sourceId: string, pathArrowSourceId?: string, po type: "symbol", source: pointSourceId!, filter: filter, + minzoom: 5, layout: { "text-font": [...MAP_EMPHASIS_TEXT_FONT_STACK], "text-field": ["coalesce", ["get", "point_label"], ""], diff --git a/src/uhm/lib/map/styles/shared/lineLabels.ts b/src/uhm/lib/map/styles/shared/lineLabels.ts index 66a1ad4..a6ac9df 100644 --- a/src/uhm/lib/map/styles/shared/lineLabels.ts +++ b/src/uhm/lib/map/styles/shared/lineLabels.ts @@ -14,6 +14,7 @@ export function getLineLabelLayers(sourceId: string): LayerSpecification[] { type: "symbol", source: sourceId, filter: ["all", LINE_GEOMETRY_FILTER, ["!=", ["coalesce", ["get", "line_label"], ""], ""]], + minzoom: 5, layout: { "text-font": [...MAP_TEXT_FONT_STACK], "symbol-placement": "line", diff --git a/src/uhm/lib/map/styles/shared/pointStyle.ts b/src/uhm/lib/map/styles/shared/pointStyle.ts index a4189bc..a0db285 100644 --- a/src/uhm/lib/map/styles/shared/pointStyle.ts +++ b/src/uhm/lib/map/styles/shared/pointStyle.ts @@ -149,8 +149,9 @@ export function buildPointGeotypeLayers( "interpolate", ["linear"], ["zoom"], - 1, 11, - 4, 13, + 1, 0, + 5, 0, + 5.01, 13, 6, 15, ], "text-anchor": "bottom", diff --git a/src/uhm/lib/map/styles/shared/polygonLabels.ts b/src/uhm/lib/map/styles/shared/polygonLabels.ts index 51a46ed..75911f0 100644 --- a/src/uhm/lib/map/styles/shared/polygonLabels.ts +++ b/src/uhm/lib/map/styles/shared/polygonLabels.ts @@ -7,6 +7,7 @@ export function getPolygonLabelLayers(sourceId: string): LayerSpecification[] { id: "polygon-labels-text", type: "symbol", source: sourceId, + minzoom: 5, layout: { "text-font": [...MAP_TEXT_FONT_STACK], "text-field": ["coalesce", ["get", "polygon_label"], ""],