import { applyCityMapSpecialBookLayout, CITY_MAP_BOOK_BOTTOM_FORBIDDEN_TOP_OFFSET, CITY_MAP_BOOK_FORBIDDEN_GAP, CITY_MAP_BOOK_HOVER_SAFE_SCALE, CITY_MAP_BOOK_TOP_FORBIDDEN_BOTTOM, cityMapBookTopForbiddenBottom, hasCompleteCityMapSpecialBookLayout, } from "./cityMapBookLayout.js?v=20260714-predeploy-010"; import { applySnapToGuides, } from "./layoutWorkbenchCore.js?v=20260714-predeploy-010"; import { buildCityTileUrl, normalizeCityTileStyles, resolveCityTileProvider, runtimeCityTileConfig, } from "./tileProviders.js?v=20260714-predeploy-010"; import { SPECIAL_BOOK_OFFSETS, SPECIAL_CATEGORY_STACK_OFFSETS, SPECIAL_DEFINITIONS, } from "./specialConfig.js?v=20260714-predeploy-010"; import { SHARE_HALFTONE_DOT_RULE, shareHalftoneCropRect, shareHalftoneDotAlpha, shareHalftoneDotRadius, shareHalftoneDotToneForPixel, shareHalftoneFeatureColorPair, shareHalftoneNoisyTone, shareHalftonePlateColors, shareHalftoneSamplePoint, } from "./shareHalftone.js?v=20260714-predeploy-010"; import { SAVED_BOOKS_STORAGE_KEY, buildFixedShelfRows, normalizeSavedBookRecords, paginateShelfItems, removeSavedBook, upsertSavedBook, } from "./savedBookshelf.js?v=20260714-predeploy-010"; import { bookshelfLocationLabel, combinedLocationHeading, locationHeading, } from "./locationLabels.js?v=20260714-predeploy-010"; import { SEARCH_PINYIN_ALIASES, SEARCH_TOPIC_GROUPS, SEARCH_TOPIC_ALIASES, deriveSearchTopicAliasMatchesFromText, deriveSearchTopicsFromText, } from "./searchTopics.js?v=20260714-predeploy-010"; import { initAnalytics, trackEvent, } from "./analytics.js?v=20260714-predeploy-010"; const VIEWBOX = { width: 1440, height: 920 }; const BASE_RADIUS = 430; const DRIFT_FRAME_MS = 1000 / 60; const DRIFT_DEGREES_PER_MS = 0.00052; const GLOBE_PATH_CACHE_LIMIT = 2048; const GLOBE_PATH_CACHE_CENTER_STEP = 0.05; const GLOBE_PATH_CACHE_RADIUS_STEP = 0.05; const HALFTONE_STEP = 14; const HALFTONE_ACTIVE_FRAME_MS = 1000 / 30; const HALFTONE_ACTIVE_AFTER_POINTER_MS = 900; const HALFTONE_SETTLE_EPSILON = 0.45; const CAPITAL_MARKER_SCALE = 1.16; const CITY_LABEL_EDGE_PADDING = 18; const GLOBAL_OCEAN_TEXTURE_TILE = 228; const CITY_OCEAN_TEXTURE_TILE = 180; const CITY_OCEAN_TRANSITION_MIN_MS = 1750; const CITY_OCEAN_TRANSITION_SETTLE_MS = 250; const CITY_OCEAN_TRANSITION_DRIFT_MS = 30000; const CITY_SPECIAL_LAND_REVEAL_MIN_MS = 220; const CITY_SPECIAL_LAND_REVEAL_MS = 460; const CITY_SPECIAL_BOOK_REVEAL_AFTER_LAND_MS = 120; const INITIAL_VIEW = { center: [132, 34], zoom: 1.78, }; const CITY_LABEL_METRICS = { width: 62, height: 18.6, zhCenterY: 5.9, enCenterY: 14.2, }; const CITY_TILE_COAST_SHADOW_RADIUS = 12; const CITY_TILE_COAST_SHADOW_MAX_ALPHA = 16; const CITY_TILE_COAST_SHADOW_FALLOFF = 1.45; const CITY_TILE_COAST_SHADOW_COLOR = [32, 79, 80]; const CITY_TILE_PIXEL_CACHE_LIMIT = 64; const CITY_TILE_PRELOAD_LIMIT = 260; const cityTilePixelCache = new Map(); const cityTileImagePreloadCache = new Map(); const citySpecialOceanTextureCache = new Set(); function cssAttributeValue(value) { return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } const continentShade = { Asia: "land-strong", Europe: "land-mid", Africa: "land-soft", "North America": "land-mid", "South America": "land-soft", Oceania: "land-faint", Antarctica: "land-ice", }; const COUNTRY_LABEL_EXCLUDED_ISO = new Set(["ATA", "HKG", "MAC", "TWN"]); const COUNTRY_LABEL_OVERRIDES = new Map([ ["CHN", "China"], ["COD", "DR Congo"], ["COG", "Congo"], ["CZE", "Czechia"], ["BIH", "Bosnia & Herzegovina"], ["CAF", "Central African Rep."], ["TLS", "Timor-Leste"], ["USA", "United States"], ["GBR", "United Kingdom"], ]); const COUNTRY_LABEL_POLICY = { overviewMaxRank: 5, detailMaxRank: 6, detailZoom: 2.28, tinyMinZoom: 2.38, microMinZoom: 2.82, }; // Keep country labels away from the clipped rim; labels entering/leaving this // zone while the globe turns otherwise flicker at the display edge. const COUNTRY_LABEL_EDGE_RATIO = 0.68; const MUSIC_TRACKS = { light: { title: "Car 24", artist: "菅野よう子", }, dark: { title: "Road to the West", artist: "The Seatbelts", }, }; const MUSIC_PREFS_KEY = "world-citybook-music"; const DEFAULT_MUSIC_VOLUME = 0.24; const ENGAGEMENT_THRESHOLDS_SECONDS = [15, 30, 60]; const TICKET_STACK_MAX_VISIBLE = 3; const TICKET_STACK_PEEK = 34; const TICKET_CUT_MAX_RADIUS = 9.5; const TICKET_CUT_BOTTOM_GAP = 7; const SHARE_TITLE_ORIGINAL_MIN_SCALE = 0.62; const SHARE_TITLE_ORIGINAL_LONG_LATIN_MIN_SCALE = 0.5; const SHARE_TITLE_ORIGINAL_SAFE_INSET = 8; const BOOKSHELF_DESKTOP_PAGE_SIZE = 12; const BOOKSHELF_MOBILE_PAGE_SIZE = 6; const BOOKSHELF_DESKTOP_ROW_SIZE = 6; const BOOKSHELF_MOBILE_ROW_SIZE = 3; const SEARCH_RESULT_LIMIT = 8; const SEARCH_MIN_QUERY_LENGTH = 1; const SEARCH_NO_RESULT_ANALYTICS_MIN_LENGTH = 2; const SEARCH_NO_RESULT_ANALYTICS_IDLE_MS = 1200; const SEARCH_BOOKMARK_REPLACE_MS = 180; const SEARCH_SUGGESTION_IDLE_MS = 950; const SEARCH_SUGGESTION_ISSUES = [ "城市记忆", "日常生活", "公共空间", "殖民史", "移民", "港口城市", "城市治理", "街区", "城市规划", "建筑", "现代性", "全球化", "边境", "城市更新", "城市空间", "地方记忆", "老街旧事", "口述史", "街道", "街巷", "多族群", "殖民遗产", "环境史", "现代主义", "城市史", "城市观察", "城市风物", "城市气质", "城市传记", "旅行非虚构", "工业", "科技", "贸易", "商业", "旅行文学", "河流", "水系", "非正规", "非正式", "山体", ]; const SEARCH_SUGGESTION_PUBLISHERS = [ "生活·读书·新知三联书店", "北京大学出版社", "南京大学出版社", "上海译文出版社", "上海人民出版社", "上海文艺出版社", ]; const SEARCH_SUGGESTION_AUTHORS = [ "陈丹燕", "大卫·哈维", "罗伯特·休斯", "简·莫里斯", "保罗·索鲁", "奥尔罕·帕慕克", ]; const SPECIAL_CATEGORY_EN = new Map([ ["城市气质", "Urban Temperament"], ["在地出版", "Local Publishing"], ["旧城底图", "Historical Base Map"], ["建筑在场", "Architecture in Place"], ["老街旧事", "Street Histories"], ["日常倒影", "Everyday Reflections"], ["线路漫游", "Walking Routes"], ]); const CITY_MAP_TOP_ROW_CATEGORIES = [ "城市气质", "在地出版", "旧城底图", "建筑在场", ]; const CITY_MAP_CATEGORY_ORDER = [ ...CITY_MAP_TOP_ROW_CATEGORIES, "老街旧事", "日常倒影", "线路漫游", ]; const CITY_MAP_CATEGORY_ORDER_BY_SPECIAL = new Map([ ["hong-kong", [ ...CITY_MAP_TOP_ROW_CATEGORIES, "日常倒影", "老街旧事", "线路漫游", ]], ]); const CITY_MAP_CATEGORY_ORDER_INDEX = new Map( CITY_MAP_CATEGORY_ORDER.map((category, index) => [category, index]), ); const CITY_MAP_CATEGORY_ORDER_INDEX_BY_SPECIAL = new Map( Array.from(CITY_MAP_CATEGORY_ORDER_BY_SPECIAL.entries(), ([specialId, order]) => [ specialId, new Map(order.map((category, index) => [category, index])), ]), ); const CITY_MAP_TOP_ROW_CATEGORY_SET = new Set(CITY_MAP_TOP_ROW_CATEGORIES); const CITY_MAP_SPECIAL_BOOK_EDGE_ALIGNMENTS = { beijing: [ { edge: "top", ids: [ "chn-beijing--douban-1070711", "chn-beijing--douban-36159853", ], }, { edge: "top", ids: [ "chn-beijing--homeshop-wear", "chn-beijing--douban-37508268", ], }, { edge: "top", ids: [ "chn-beijing--douban-25751679", "chn-beijing--douban-37131044", ], }, { edge: "bottom", ids: [ "chn-beijing--douban-33395164", "chn-beijing--douban-30469133", "chn-beijing--douban-34991723", "chn-beijing--douban-37134106", "chn-beijing--isbn-9787540265052", "chn-beijing--douban-26580056", ], }, ], "hong-kong": [ { edge: "bottom", ids: [ "chn-hong-kong--douban-35452941", "chn-hong-kong--douban-36285315", "chn-hong-kong--manual-xiangtu-hongkong", "chn-hong-kong--external-sham-shui-po-101", ], }, { edge: "top", ids: [ "chn-hong-kong--douban-27004436", "chn-hong-kong--manual-power-generation-urban-renewal", ], }, ], }; const SHARE_HALFTONE_SPECIAL_PALETTES = new Map([ ["lka-colombo--douban-36458135", [ { r: 98, g: 164, b: 132 }, { r: 247, g: 187, b: 166 }, ]], ["mmr-yangon--douban-35166731", [ { r: 211, g: 134, b: 99 }, { r: 245, g: 189, b: 120 }, ]], ["jpn-tokyo--douban-36448234", [ { r: 243, g: 232, b: 11 }, { r: 35, g: 24, b: 3 }, ]], ["kor-seoul--external-seoul-mini-mountains", [ { r: 213, g: 206, b: 199 }, { r: 245, g: 106, b: 193 }, ]], ]); let globeFeaturePathId = 0; const GLOBAL_TOOLTIP_SAFE_ZONE = { leftRatio: 0.42, minLeft: 520, top: 112, right: 28, bottom: 78, inset: 18, heroRightPadding: 28, footerTopPadding: 10, }; const DETAIL_COPY_ANCHOR_RATIO = 0.70; const DETAIL_HERO_CLEARANCE = 20; const DETAIL_WIDE_CARD_THRESHOLD = 860; const DETAIL_WIDE_HERO_OVERLAP = 112; const TOOLTIP_CARD_ANCHOR_RATIO = 0.33; const CITY_SPECIAL_BOOK_SAFE_ZONE = { side: 86, mobileSide: 70, top: 142, mobileTop: 112, bottom: 190, mobileBottom: 148, }; const state = { center: [...INITIAL_VIEW.center], zoom: INITIAL_VIEW.zoom, theme: "light", dragging: null, driftFrame: null, lastDriftAt: 0, activePoint: null, hoverPoint: null, tooltipPoint: null, activeBookIndexes: new Map(), savedBooks: new Set(), savedBookRecords: [], bookshelfOpen: false, bookshelfPage: 0, bookshelfHoverPointer: null, bookshelfHoverObserver: null, bookshelfRenderSignature: "", bookCardMode: "ticket", detailOrigin: null, detailReturnTarget: null, tooltipPointer: null, activeSpecialId: null, returnViewBeforeSpecial: null, activeSpecialStackKey: null, citySpecialReadyTimer: null, citySpecialLandRevealTimer: null, specialStackIndexes: new Map(), specialStackRenderData: new Map(), specialBookDrag: null, specialBookDraftPoints: new Map(), specialBookRenderData: new Map(), specialBookSuppressedClickKey: null, activeMapStyleId: "", customMapboxStyleId: "", viewAnimationFrame: null, globePathCache: new Map(), worldRenderKeys: "", search: { index: [], query: "", results: [], activeIndex: -1, open: false, suggestion: null, suggestionTimer: null, suggestionRolls: 0, acceptedSuggestions: 0, shownSuggestions: new Set(), }, searchNavigation: { token: 0, closeTimer: null, }, analytics: { searchStarted: false, lastNoResultQuery: "", pendingNoResultQuery: "", noResultTimer: null, engagementTimers: [], engagementMarks: new Set(), assetLoadTrackingReady: false, }, shareControlsHideTimer: null, audio: { context: null, musicTrack: "light", musicPlaying: false, musicMuted: false, musicVolume: DEFAULT_MUSIC_VOLUME, musicDrag: null, musicFadeFrame: null, musicVolumeTimer: null, cityMapLoading: null, }, halftone: { initialized: false, frame: null, resizeTimer: null, context: null, width: 0, height: 0, dpr: 1, pointerX: 0, pointerY: 0, targetX: 0, targetY: 0, pulse: 0, lastDrawAt: 0, activeUntil: 0, reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches, }, }; const elements = { mapStage: document.querySelector(".map-stage"), halftoneCanvas: document.querySelector("#halftoneCanvas"), cityTileLayer: document.querySelector("#cityTileLayer"), mapSvg: document.querySelector("#mapSvg"), themeToggle: document.querySelector("#themeToggle"), bookshelfToggle: document.querySelector("#bookshelfToggle"), bookshelfCount: document.querySelector("#bookshelfCount"), bookshelfView: document.querySelector("#bookshelfView"), bookshelfClose: document.querySelector("#bookshelfClose"), bookshelfGrid: document.querySelector("#bookshelfGrid"), bookshelfEmpty: document.querySelector("#bookshelfEmpty"), bookshelfPager: document.querySelector("#bookshelfPager"), bookshelfPrev: document.querySelector("#bookshelfPrev"), bookshelfNext: document.querySelector("#bookshelfNext"), bookshelfPageLabel: document.querySelector("#bookshelfPageLabel"), specialBackButton: document.querySelector("#specialBackButton"), globeLayer: document.querySelector("#globeLayer"), worldLayer: document.querySelector("#worldLayer"), highlightLayer: document.querySelector("#highlightLayer"), cityLayer: document.querySelector("#cityLayer"), cityLabelLayer: document.querySelector("#cityLabelLayer"), specialOverlaySvg: document.querySelector("#specialOverlaySvg"), specialLabelLayer: document.querySelector("#specialLabelLayer"), specialBookLayer: document.querySelector("#specialBookLayer"), cityTooltip: document.querySelector("#cityTooltip"), sharePreview: document.querySelector("#sharePreview"), heroCopy: document.querySelector(".hero-copy"), heroKicker: document.querySelector(".hero-kicker"), heroTagline: document.querySelector(".hero-tagline"), searchCapsule: document.querySelector(".search-capsule"), searchInput: document.querySelector(".search-capsule input"), searchClear: document.querySelector(".search-clear"), searchSuggestion: document.querySelector(".search-suggestion"), searchResults: document.querySelector("#searchResults"), musicSignature: document.querySelector("#musicSignature"), musicToggle: document.querySelector("#musicToggle"), musicTitle: document.querySelector("#musicTitle"), musicArtist: document.querySelector("#musicArtist"), musicVolume: document.querySelector("#musicVolume"), musicLight: document.querySelector("#musicLight"), musicDark: document.querySelector("#musicDark"), oceanWaves: document.querySelector("#oceanWaves"), oceanWaveImage: document.querySelector("#oceanWaveImage"), landTexture: document.querySelector("#landTexture"), landTextureImage: document.querySelector("#landTextureImage"), }; const mapData = { worldFeatures: [], chinaFeatures: [], cities: [], cityCatalog: [], books: [], specials: [], specialBoundaries: new Map(), specialContexts: new Map(), }; let specialDataLoadPromise = null; const specialDataLoadPromises = new Map(); const DATA_VERSION = "20260714-predeploy-010"; const SHARE_HALFTONE_REVEAL_WAIT_MS = 1100; const SHARE_HALFTONE_CACHE_LIMIT = 10; const SHARE_HALFTONE_PREWARM_TIMEOUT_MS = 350; const SHARE_HALFTONE_RENDER_CONCURRENCY = 1; const SHARE_HALFTONE_RENDER_SCALE = 1.5; const SHARE_HALFTONE_RADIUS_BUCKETS = 28; const SHARE_HALFTONE_RENDER_YIELD_ROWS = 12; const SHARE_HALFTONE_RENDER_YIELD_BUCKETS = 8; const BOOK_COVER_PRELOAD_LIMIT = 96; const BOOK_COVER_PRELOAD_NEIGHBORS = 2; const SHARE_HALFTONE_PRIORITY = { prewarm: 1, immediate: 2, visible: 3, }; const SHARE_HALFTONE_PREWARM_NEIGHBORS = 1; const SPECIAL_CITY_GLOBAL_BOOK_LIMIT = 2; const SPECIAL_CITY_IDS = new Set(SPECIAL_DEFINITIONS.map((special) => special.cityId)); const cityMarkerCache = new Map(); const specialLabelCache = new Map(); const bookCoverPreloadCache = new Map(); const shareHalftoneCache = new Map(); const shareHalftoneRenderQueue = []; let shareHalftoneActiveRenders = 0; const shareHalftoneActiveJobs = new Set(); async function init() { const initParams = new URLSearchParams(window.location.search); const shareSnapshotBookId = initParams.get("shareTicketBookId"); if (shareSnapshotBookId) { await initShareSnapshot(shareSnapshotBookId); return; } setupHalftoneCanvas(); await initAnalytics(); const [world, cityDb, pointDb, bookDb] = await Promise.all([ fetchJson("public/maps/world/countries-110m.geojson").catch(() => fetchJson("public/maps/world/countries-50m.geojson")), fetchJson("public/data/world-citybook/cities.json").catch(() => ({ cities: [] })), fetchJson("public/data/world-citybook/citybook-points.json").catch(() => ({ points: [] })), fetchJson("public/data/world-citybook/books.json").catch(() => ({ books: [] })), ]); mapData.worldFeatures = prepareWorldFeatures(world); mapData.cities = normalizeCities(pointDb.points).filter((point) => pointBooks(point).length); mapData.cityCatalog = normalizeCities(cityDb.cities); mapData.books = normalizeBookRecords(bookDb.books); mapData.specials = buildSpecials(); state.search.index = buildSearchIndex(); loadSavedBooks(); setupMapStyleControl(); warmCitySpecialOceanTexture(); render(); wireEvents(); setupEngagementTracking(); } async function initShareSnapshot(bookId) { document.documentElement.dataset.shareSnapshotReady = "loading"; try { const params = new URLSearchParams(window.location.search); const theme = params.get("theme"); if (theme === "light" || theme === "dark") { state.theme = theme; document.documentElement.dataset.theme = state.theme; } const [cityDb, pointDb, bookDb] = await Promise.all([ fetchJson("public/data/world-citybook/cities.json").catch(() => ({ cities: [] })), fetchJson("public/data/world-citybook/citybook-points.json").catch(() => ({ points: [] })), fetchJson("public/data/world-citybook/books.json").catch(() => ({ books: [] })), ]); mapData.cities = normalizeCities(pointDb.points).filter((point) => pointBooks(point).length); mapData.cityCatalog = normalizeCities(cityDb.cities); mapData.books = normalizeBookRecords(bookDb.books); mapData.specials = buildSpecials(); const isExportSnapshot = params.get("shareSnapshot") === "1"; await openShareSnapshotBook(bookId, { renderMap: false, halftoneMaxWait: isExportSnapshot ? 10000 : 1800, }); } catch (error) { console.error("Failed to render share snapshot", error); document.documentElement.dataset.shareSnapshotError = error.message || "Failed to render share snapshot"; document.documentElement.dataset.shareSnapshotReady = "failed"; } } function wireEvents() { elements.themeToggle.addEventListener("click", () => { playUiSound("radioControl"); state.theme = state.theme === "light" ? "dark" : "light"; applyTheme({ syncMusic: true }); trackEvent("theme_changed", { theme: state.theme, source_view: "theme_toggle", }); }); elements.bookshelfToggle?.addEventListener("click", () => { playUiSound("bookmarkOpen"); openBookshelf(); }); elements.bookshelfClose?.addEventListener("click", () => { playUiSound("paperClose"); closeBookshelf(); }); elements.bookshelfPrev?.addEventListener("click", () => { pageBookshelf(-1); }); elements.bookshelfNext?.addEventListener("click", () => { pageBookshelf(1); }); elements.bookshelfGrid?.addEventListener("click", (event) => { const item = event.target.closest(".bookshelf-book[data-book-id]"); if (!item) return; openShelfBook(item.dataset.bookId); }); elements.bookshelfGrid?.addEventListener("pointerover", syncBookshelfHoverFromPointer); elements.bookshelfGrid?.addEventListener("mouseover", syncBookshelfHoverFromPointer); elements.bookshelfGrid?.addEventListener("pointermove", syncBookshelfHoverFromPointer); elements.bookshelfGrid?.addEventListener("mousemove", syncBookshelfHoverFromPointer); elements.bookshelfGrid?.addEventListener("pointerleave", clearBookshelfHover); elements.bookshelfGrid?.addEventListener("mouseleave", clearBookshelfHover); elements.bookshelfView?.addEventListener("pointerover", syncBookshelfHoverFromPointer); elements.bookshelfView?.addEventListener("mouseover", syncBookshelfHoverFromPointer); elements.bookshelfView?.addEventListener("pointermove", syncBookshelfHoverFromPointer); elements.bookshelfView?.addEventListener("mousemove", syncBookshelfHoverFromPointer); elements.bookshelfView?.addEventListener("pointerleave", clearBookshelfHover); elements.bookshelfView?.addEventListener("mouseleave", clearBookshelfHover); setupBookshelfHoverObserver(); elements.searchCapsule?.addEventListener("submit", (event) => { event.preventDefault(); activateSearchResult(state.search.activeIndex >= 0 ? state.search.activeIndex : 0); }); elements.searchInput?.addEventListener("input", () => { hideSearchSuggestion(); updateSearchQuery(elements.searchInput.value); }); elements.searchInput?.addEventListener("focus", () => { updateSearchQuery(elements.searchInput.value, { openIfEmpty: false }); scheduleSearchSuggestion(); }); elements.searchInput?.addEventListener("keydown", handleSearchKeydown); elements.searchSuggestion?.addEventListener("pointerdown", (event) => { event.preventDefault(); }); elements.searchSuggestion?.addEventListener("click", () => { confirmSearchSuggestion(); }); elements.searchResults?.addEventListener("pointerdown", (event) => { event.preventDefault(); }); elements.searchResults?.addEventListener("pointerover", syncSearchActiveFromPointer); elements.searchResults?.addEventListener("pointermove", syncSearchActiveFromPointer); elements.searchResults?.addEventListener("mouseover", syncSearchActiveFromPointer); elements.searchResults?.addEventListener("click", (event) => { const option = event.target.closest(".search-result[data-result-index]"); if (!option) return; activateSearchResult(Number(option.dataset.resultIndex)); }); document.addEventListener("pointerdown", (event) => { if (!elements.searchCapsule?.contains(event.target)) { closeSearchResults(); hideSearchSuggestion(); } }); elements.searchClear?.addEventListener("click", () => { playUiSound("softTap"); elements.searchInput.value = ""; elements.searchInput.focus(); hideSearchSuggestion(); updateSearchQuery(""); scheduleSearchSuggestion(); }); elements.specialBackButton.addEventListener("click", () => { exitSpecialView({ playSound: true }); }); elements.mapSvg.addEventListener("pointerdown", (event) => { if (state.activeSpecialId) return; elements.mapSvg.setPointerCapture(event.pointerId); state.dragging = { x: event.clientX, y: event.clientY, center: [...state.center], startedOnCityPoint: Boolean(event.target.closest(".city-html-marker, .special-capsule")), renderFrame: null, }; elements.mapSvg.classList.add("is-dragging"); }); elements.mapSvg.addEventListener("pointermove", (event) => { if (!state.dragging) return; const dx = event.clientX - state.dragging.x; const dy = event.clientY - state.dragging.y; state.dragging.moved = state.dragging.moved || Math.abs(dx) > 4 || Math.abs(dy) > 4; const sensitivity = 0.28 / state.zoom; state.center = [ normalizeLongitude(state.dragging.center[0] - dx * sensitivity), clamp(state.dragging.center[1] + dy * sensitivity, -72, 72), ]; scheduleMapDragRender(); }); elements.mapSvg.addEventListener("pointerup", endDrag); elements.mapSvg.addEventListener("pointercancel", endDrag); window.addEventListener("keydown", (event) => { if (event.key === "0") { exitSpecialView({ playSound: true }); } else if (event.key === "Escape") { if (state.bookshelfOpen) { closeBookshelf(); } else if (elements.sharePreview && !elements.sharePreview.hidden) { closeSharePreview({ reason: "escape" }); } else if (state.activePoint && elements.cityTooltip && !elements.cityTooltip.hidden) { handleTooltipClose(); } else if (state.activeSpecialId) { exitSpecialView({ playSound: true }); } } }); elements.cityTooltip.addEventListener("click", (event) => { const target = event.target.closest("button"); const ticket = event.target.closest(".book-ticket[data-book-index]"); if (ticket && !target) { if (shouldKeepTooltipTextSelection(event)) return; selectBookTicket(ticket, { rerender: !target }); } if (!target) return; if (target.classList.contains("book-card__save")) { toggleActiveBookSave(); } else if (target.classList.contains("book-card__detail")) { playUiSound("bookmarkOpen"); enterBookDetailMode(); } else if (target.classList.contains("book-card__share")) { playUiSound("softTap"); openSharePreview(); } else if (target.classList.contains("book-card__city-page--prev")) { pageActiveCity(-1); } else if (target.classList.contains("book-card__city-page--next")) { pageActiveCity(1); } else if (target.classList.contains("book-card__page--prev")) { pageActiveBook(-1); } else if (target.classList.contains("book-card__page--next")) { pageActiveBook(1); } else if (target.classList.contains("book-card__close")) { playUiSound("paperClose"); handleTooltipClose(); } }); elements.cityTooltip.addEventListener("pointerdown", (event) => { state.tooltipPointer = { x: event.clientX, y: event.clientY, }; }); elements.cityTooltip.addEventListener("pointercancel", () => { state.tooltipPointer = null; }); elements.cityTooltip.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; const ticket = event.target.closest(".book-ticket[data-book-index]"); if (!ticket) return; event.preventDefault(); selectBookTicket(ticket, { rerender: true }); }); elements.sharePreview?.addEventListener("click", (event) => { const target = event.target.closest("button"); if (!target) return; if (target.classList.contains("share-preview__close")) { playUiSound("paperClose"); closeSharePreview({ reason: "button" }); } else if (target.classList.contains("share-preview__save")) { saveShareTicketImage().catch((error) => { console.error("Failed to save share ticket", error); }); } else if (target.classList.contains("share-preview__page--prev")) { pageSharePreview(-1); } else if (target.classList.contains("share-preview__page--next")) { pageSharePreview(1); } }); elements.sharePreview?.addEventListener("pointerover", (event) => { const target = event.target instanceof Element ? event.target : null; if (target?.closest(".share-ticket, .share-preview__save, .share-preview__close")) revealSharePreviewControls(); }); elements.sharePreview?.addEventListener("pointermove", (event) => { const target = event.target instanceof Element ? event.target : null; if (target?.closest(".share-ticket, .share-preview__save, .share-preview__close")) revealSharePreviewControls(); else if (elements.sharePreview.classList.contains("has-share-controls")) scheduleSharePreviewControlsHide(); }); elements.sharePreview?.addEventListener("pointerout", (event) => { const target = event.target instanceof Element ? event.target : null; const next = event.relatedTarget instanceof Element ? event.relatedTarget : null; const nextIsControl = Boolean(next?.closest(".share-ticket, .share-preview__save, .share-preview__close")); const leavingInteractiveSurface = Boolean(target?.closest(".share-ticket, .share-preview__save, .share-preview__close")) && !nextIsControl; if (!next || !elements.sharePreview.contains(next) || leavingInteractiveSurface) scheduleSharePreviewControlsHide(); }); window.addEventListener("resize", () => { scheduleTicketCutPaths(); renderBookshelf(); syncSearchResultsBounds(); }, { passive: true }); window.visualViewport?.addEventListener("resize", syncSearchResultsBounds, { passive: true }); setupHeroLineWidth(); setupMusicSignature(); setupAssetLoadFailureTracking(); startSubtleDrift(); } function setupEngagementTracking() { if (state.analytics.engagementTimers.length) return; state.analytics.engagementTimers = ENGAGEMENT_THRESHOLDS_SECONDS.map((seconds) => setTimeout(() => { if (state.analytics.engagementMarks.has(seconds)) return; state.analytics.engagementMarks.add(seconds); trackEvent("session_engaged", { duration_seconds: seconds, }); }, seconds * 1000)); } function setupAssetLoadFailureTracking() { if (state.analytics.assetLoadTrackingReady) return; state.analytics.assetLoadTrackingReady = true; window.addEventListener("error", (event) => { const target = event.target; const tagName = String(target?.tagName || "").toLowerCase(); if (!["img", "audio", "source", "video"].includes(tagName)) return; const src = target.currentSrc || target.src || target.getAttribute?.("src") || ""; trackEvent("asset_load_failed", { tag_name: tagName, asset_path: analyticsAssetPath(src), }); }, true); } function setupMapStyleControl() { const styles = normalizeCityTileStyles(runtimeCityTileConfig()); const runtimeConfig = runtimeCityTileConfig(); state.activeMapStyleId = runtimeConfig.activeMapStyleId || runtimeConfig.defaultMapStyleId || runtimeConfig.defaultMapboxStyleKey || styles[0]?.id || "light"; state.customMapboxStyleId = runtimeConfig.customMapboxStyleId || runtimeConfig.mapboxStyleId || runtimeConfig.mapbox?.styleId || ""; if (!styles.some((style) => style.id === state.activeMapStyleId)) { state.activeMapStyleId = styles[0]?.id || "light"; } } function currentTileRuntimeConfig() { return { ...runtimeCityTileConfig(), activeMapStyleId: state.activeMapStyleId, customMapboxStyleId: state.customMapboxStyleId, }; } function setupHeroLineWidth() { syncHeroLineWidth(); document.fonts?.ready.then(syncHeroLineWidth).catch(() => {}); window.addEventListener("resize", syncHeroLineWidth, { passive: true }); } function syncHeroLineWidth() { if (!elements.heroCopy || !elements.heroTagline) return; const width = elements.heroTagline.getBoundingClientRect().width; if (!Number.isFinite(width) || width <= 0) return; elements.heroCopy.style.setProperty("--hero-line-width", `${width.toFixed(2)}px`); } function audioContext() { const AudioContextClass = window.AudioContext || window.webkitAudioContext; if (!AudioContextClass) return null; if (!state.audio.context) state.audio.context = new AudioContextClass(); return state.audio.context; } function withAudioContext(callback) { const context = audioContext(); if (!context) return; if (context.state === "suspended") { context.resume().catch(() => {}); } callback(context); } function playUiSound(cue) { switch (cue) { case "softTap": playSoftTapSound(); break; case "pageFlick": playPageFlickSound(); break; case "bookmarkOpen": playBookmarkOpenSound(); break; case "paperClose": playPaperCloseSound(); break; case "saveStamp": playSaveStampSound(); break; case "coinCollect": playCoinCollectSound(); break; case "cityMapLoading": playCityMapLoadingSound(); break; case "radioControl": playRadioControlSound(); break; default: break; } } function playSoftTapSound() { withAudioContext((context) => { const now = context.currentTime; playPageNoiseLayer(context, now, 0.055, 1250, 760, 0.009, 1.14); playToneLayer(context, now + 0.006, "triangle", 214, 184, 0.07, 0.006); }); } function playPageFlickSound() { withAudioContext((context) => { const now = context.currentTime; playPageNoiseLayer(context, now, 0.15, 2100, 680, 0.022, 1.1); playPageNoiseLayer(context, now + 0.04, 0.105, 1160, 420, 0.014, 0.86); playToneLayer(context, now + 0.1, "triangle", 116, 76, 0.11, 0.009); }); } function playBookmarkOpenSound() { withAudioContext((context) => { const now = context.currentTime; playPageNoiseLayer(context, now, 0.24, 1620, 520, 0.029, 0.76); playPageNoiseLayer(context, now + 0.072, 0.17, 2450, 820, 0.018, 1.04); playToneLayer(context, now + 0.15, "triangle", 132, 92, 0.17, 0.014); }); } function playPaperCloseSound() { withAudioContext((context) => { const now = context.currentTime; playPageNoiseLayer(context, now, 0.11, 940, 340, 0.016, 0.82); playToneLayer(context, now + 0.025, "triangle", 96, 68, 0.12, 0.012); }); } function playSaveStampSound() { withAudioContext((context) => { const now = context.currentTime; playPageNoiseLayer(context, now, 0.08, 1420, 620, 0.014, 1); playToneLayer(context, now + 0.025, "square", 154, 112, 0.095, 0.012); playToneLayer(context, now + 0.052, "triangle", 308, 246, 0.105, 0.006); }); } function playCoinCollectSound() { withAudioContext((context) => { const now = context.currentTime; playToneLayer(context, now, "triangle", 784, 1047, 0.064, 0.018); playToneLayer(context, now + 0.05, "square", 1047, 1568, 0.076, 0.014); playToneLayer(context, now + 0.012, "sine", 1568, 2093, 0.052, 0.0045); playToneLayer(context, now + 0.07, "triangle", 392, 523, 0.105, 0.006); }); } function playCityMapLoadingSound() { withAudioContext((context) => { const now = context.currentTime; stopCityMapLoadingSound({ fadeDuration: 0.08, finalPulses: 0 }); startCityMapLoadingLoop(context, now); }); } function playCityMapLoadingIntroSound(context, now = context.currentTime, destination = context.destination) { playPageNoiseLayer(context, now, 0.18, 980, 520, 0.01, 0.82, destination); playToneLayer(context, now + 0.02, "sine", 196, 246, 0.13, 0.012, destination); playToneLayer(context, now + 0.13, "triangle", 246, 329, 0.15, 0.011, destination); playToneLayer(context, now + 0.28, "sine", 329, 392, 0.16, 0.009, destination); } function startCityMapLoadingLoop(context, startAt = context.currentTime) { const masterGain = context.createGain(); masterGain.gain.setValueAtTime(0.0001, startAt); masterGain.gain.exponentialRampToValueAtTime(1, startAt + 0.18); masterGain.connect(context.destination); const loading = { context, masterGain, stopping: false, stopped: false, repeatTimer: null, stopTimer: null, }; state.audio.cityMapLoading = loading; scheduleCityMapLoadingRepeat(loading, Math.max(0, (startAt - context.currentTime) * 1000)); } function scheduleCityMapLoadingRepeat(loading, delayMs = 0) { if (!loading || loading.stopped) return; loading.repeatTimer = window.setTimeout(() => { if (loading.stopped || loading.stopping) return; playCityMapLoadingIntroSound(loading.context, loading.context.currentTime, loading.masterGain); scheduleCityMapLoadingRepeat(loading, 560); }, delayMs); } function stopCityMapLoadingSound(options = {}) { const loading = state.audio.cityMapLoading; if (!loading) return; loading.stopping = true; if (loading.repeatTimer) window.clearTimeout(loading.repeatTimer); if (loading.stopTimer) window.clearTimeout(loading.stopTimer); const fadeDuration = Math.max(0.04, options.fadeDuration ?? 0.9); const context = loading.context; const now = context.currentTime; loading.masterGain.gain.cancelScheduledValues(now); loading.masterGain.gain.setValueAtTime(Math.max(loading.masterGain.gain.value, 0.0001), now); loading.masterGain.gain.exponentialRampToValueAtTime(0.0001, now + fadeDuration); loading.stopTimer = window.setTimeout(() => { loading.stopped = true; if (loading.repeatTimer) window.clearTimeout(loading.repeatTimer); loading.masterGain.disconnect(); if (state.audio.cityMapLoading === loading) state.audio.cityMapLoading = null; }, Math.ceil((fadeDuration + 0.04) * 1000)); } function playRadioControlSound() { withAudioContext((context) => { const now = context.currentTime; playToneLayer(context, now, "sine", 174, 220, 0.105, 0.018); playToneLayer(context, now + 0.012, "triangle", 520, 610, 0.09, 0.007); }); } function playToneLayer(context, startAt, type, startFrequency, endFrequency, duration, peakGain, destination = context.destination) { const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.type = type; oscillator.frequency.setValueAtTime(startFrequency, startAt); oscillator.frequency.exponentialRampToValueAtTime(endFrequency, startAt + duration); gain.gain.setValueAtTime(0.0001, startAt); gain.gain.exponentialRampToValueAtTime(peakGain, startAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); oscillator.connect(gain).connect(destination); oscillator.start(startAt); oscillator.stop(startAt + duration + 0.02); } function playGlowSound() { playUiSound("radioControl"); } function setupMusicSignature() { if (!elements.musicSignature || !elements.musicToggle) return; loadMusicPrefs(); syncMusicTrackForTheme({ force: true }); applyMusicVolumeToAll(); updateMusicSignature(); elements.musicToggle.addEventListener("pointerdown", beginMusicControlDrag); elements.musicToggle.addEventListener("pointermove", moveMusicControlDrag); elements.musicToggle.addEventListener("pointerup", endMusicControlDrag); elements.musicToggle.addEventListener("pointercancel", cancelMusicControlDrag); elements.musicToggle.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); toggleMusicPlayback(); }); } function loadMusicPrefs() { try { const prefs = JSON.parse(localStorage.getItem(MUSIC_PREFS_KEY) || "{}"); if (Number.isFinite(prefs.volume)) { state.audio.musicVolume = clamp(prefs.volume, 0, 1); } if (typeof prefs.muted === "boolean") { state.audio.musicMuted = prefs.muted; } } catch (error) { state.audio.musicVolume = DEFAULT_MUSIC_VOLUME; state.audio.musicMuted = false; } } function saveMusicPrefs() { try { localStorage.setItem(MUSIC_PREFS_KEY, JSON.stringify({ volume: state.audio.musicVolume, muted: state.audio.musicMuted, })); } catch (error) { // Music preferences are decorative; storage failures should never block the map. } } function beginMusicControlDrag(event) { if (event.button !== 0) return; event.preventDefault(); elements.musicToggle.setPointerCapture(event.pointerId); state.audio.musicDrag = { pointerId: event.pointerId, startY: event.clientY, startVolume: state.audio.musicVolume, moved: false, }; } function moveMusicControlDrag(event) { const drag = state.audio.musicDrag; if (!drag || drag.pointerId !== event.pointerId) return; const deltaY = drag.startY - event.clientY; if (!drag.moved && Math.abs(deltaY) < 8) return; drag.moved = true; updateMusicVolumeFromDrag(event.clientY); } function endMusicControlDrag(event) { const drag = state.audio.musicDrag; if (!drag || drag.pointerId !== event.pointerId) return; if (elements.musicToggle.hasPointerCapture(event.pointerId)) { elements.musicToggle.releasePointerCapture(event.pointerId); } state.audio.musicDrag = null; if (drag.moved) { playUiSound("radioControl"); saveMusicPrefs(); scheduleMusicVolumeHide(); trackEvent("music_volume_changed", { source_view: "music_control", track: state.audio.musicTrack, volume_percent: Math.round(state.audio.musicVolume * 100), muted: state.audio.musicMuted, }); } else { toggleMusicPlayback(); } } function cancelMusicControlDrag(event) { const drag = state.audio.musicDrag; if (!drag || drag.pointerId !== event.pointerId) return; if (elements.musicToggle.hasPointerCapture(event.pointerId)) { elements.musicToggle.releasePointerCapture(event.pointerId); } state.audio.musicDrag = null; scheduleMusicVolumeHide(); } function updateMusicVolumeFromDrag(clientY) { const drag = state.audio.musicDrag; if (!drag) return; const nextVolume = clamp(drag.startVolume + ((drag.startY - clientY) / 150), 0, 1); state.audio.musicVolume = nextVolume; state.audio.musicMuted = nextVolume <= 0.01; if (nextVolume > 0.01 && !state.audio.musicPlaying) { startMusicPlayback(); } applyMusicVolumeToAll(); showMusicVolume(); updateMusicSignature(); } function toggleMusicPlayback() { playUiSound("radioControl"); if (!state.audio.musicPlaying) { state.audio.musicMuted = false; startMusicPlayback(); } else { state.audio.musicMuted = !state.audio.musicMuted; applyMusicVolumeToAll(); updateMusicSignature(); } saveMusicPrefs(); trackEvent(state.audio.musicMuted ? "music_paused" : "music_played", { source_view: "music_control", track: state.audio.musicTrack, volume_percent: Math.round(state.audio.musicVolume * 100), muted: state.audio.musicMuted, }); } function startMusicPlayback() { const audio = activeMusicAudio(); if (!audio) return; state.audio.musicPlaying = true; state.audio.musicMuted = state.audio.musicVolume <= 0.01 ? true : state.audio.musicMuted; applyMusicVolumeToAll(); audio.play().then(updateMusicSignature).catch(() => { state.audio.musicPlaying = false; updateMusicSignature(); }); } function activeMusicAudio() { return state.audio.musicTrack === "dark" ? elements.musicDark : elements.musicLight; } function inactiveMusicAudio() { return state.audio.musicTrack === "dark" ? elements.musicLight : elements.musicDark; } function effectiveMusicVolume() { return state.audio.musicMuted ? 0 : state.audio.musicVolume; } function applyMusicVolumeToAll() { const volume = effectiveMusicVolume(); [elements.musicLight, elements.musicDark].forEach((audio) => { if (audio) audio.volume = volume; }); elements.musicSignature?.style.setProperty("--music-level", String(state.audio.musicVolume)); } function syncMusicTrackForTheme(options = {}) { if (!elements.musicLight || !elements.musicDark) return; const nextTrack = state.theme === "dark" ? "dark" : "light"; const previousAudio = activeMusicAudio(); const trackChanged = nextTrack !== state.audio.musicTrack; state.audio.musicTrack = nextTrack; const nextAudio = activeMusicAudio(); const inactiveAudio = inactiveMusicAudio(); if (inactiveAudio && inactiveAudio !== nextAudio && !trackChanged) { inactiveAudio.pause(); } if (trackChanged && state.audio.musicPlaying) { switchMusicTrack(previousAudio, nextAudio, Boolean(options.syncMusic)); } else { applyMusicVolumeToAll(); } updateMusicSignature(); } function switchMusicTrack(previousAudio, nextAudio, withFade) { if (!nextAudio || previousAudio === nextAudio) return; if (state.audio.musicFadeFrame) cancelAnimationFrame(state.audio.musicFadeFrame); const targetVolume = effectiveMusicVolume(); nextAudio.volume = withFade ? 0 : targetVolume; nextAudio.play().catch(() => { state.audio.musicPlaying = false; updateMusicSignature(); }); if (!withFade || !previousAudio) { previousAudio?.pause(); if (previousAudio) previousAudio.volume = targetVolume; nextAudio.volume = targetVolume; return; } const startedAt = performance.now(); const duration = 520; const step = (timestamp) => { const progress = clamp((timestamp - startedAt) / duration, 0, 1); previousAudio.volume = targetVolume * (1 - progress); nextAudio.volume = targetVolume * progress; if (progress < 1) { state.audio.musicFadeFrame = requestAnimationFrame(step); return; } previousAudio.pause(); previousAudio.volume = targetVolume; nextAudio.volume = targetVolume; state.audio.musicFadeFrame = null; }; state.audio.musicFadeFrame = requestAnimationFrame(step); } function updateMusicSignature() { if (!elements.musicSignature) return; const track = MUSIC_TRACKS[state.audio.musicTrack] || MUSIC_TRACKS.light; if (elements.musicTitle) elements.musicTitle.textContent = track.title; if (elements.musicArtist) elements.musicArtist.textContent = track.artist; if (elements.musicVolume) elements.musicVolume.textContent = `${Math.round(state.audio.musicVolume * 100)}%`; const mutedOrPaused = state.audio.musicMuted || !state.audio.musicPlaying; elements.musicSignature.classList.toggle("is-playing", state.audio.musicPlaying && !state.audio.musicMuted); elements.musicSignature.classList.toggle("is-muted", mutedOrPaused); elements.musicToggle?.classList.toggle("is-muted", mutedOrPaused); elements.musicToggle?.setAttribute("aria-label", musicToggleLabel()); } function musicToggleLabel() { if (!state.audio.musicPlaying) return "播放背景音乐,长按上下拖动调节音量"; if (state.audio.musicMuted) return "开启背景音乐,长按上下拖动调节音量"; return "静音背景音乐,长按上下拖动调节音量"; } function showMusicVolume() { if (!elements.musicSignature) return; window.clearTimeout(state.audio.musicVolumeTimer); elements.musicSignature.classList.add("is-adjusting"); } function scheduleMusicVolumeHide() { if (!elements.musicSignature) return; window.clearTimeout(state.audio.musicVolumeTimer); state.audio.musicVolumeTimer = window.setTimeout(() => { elements.musicSignature.classList.remove("is-adjusting"); }, 760); } function playPageSound() { playUiSound("pageFlick"); } function playPageNoiseLayer(context, startAt, duration, startFrequency, endFrequency, peakGain, playbackRate, destination = context.destination) { const sampleRate = context.sampleRate; const frameCount = Math.max(1, Math.floor(sampleRate * duration)); const buffer = context.createBuffer(1, frameCount, sampleRate); const samples = buffer.getChannelData(0); let previous = 0; for (let index = 0; index < frameCount; index += 1) { const progress = index / frameCount; const attack = Math.min(progress / 0.08, 1); const release = Math.pow(1 - progress, 1.55); const white = Math.random() * 2 - 1; previous = previous * 0.72 + white * 0.28; samples[index] = previous * attack * release; } const source = context.createBufferSource(); const filter = context.createBiquadFilter(); const gain = context.createGain(); source.buffer = buffer; source.playbackRate.setValueAtTime(playbackRate, startAt); filter.type = "bandpass"; filter.frequency.setValueAtTime(startFrequency, startAt); filter.frequency.exponentialRampToValueAtTime(endFrequency, startAt + duration); filter.Q.setValueAtTime(0.64, startAt); gain.gain.setValueAtTime(0.0001, startAt); gain.gain.exponentialRampToValueAtTime(peakGain, startAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); source.connect(filter).connect(gain).connect(destination); source.start(startAt); source.stop(startAt + duration + 0.02); } function playPageSettleLayer(context, startAt) { const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.type = "triangle"; oscillator.frequency.setValueAtTime(118, startAt); oscillator.frequency.exponentialRampToValueAtTime(74, startAt + 0.11); gain.gain.setValueAtTime(0.0001, startAt); gain.gain.exponentialRampToValueAtTime(0.012, startAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + 0.13); oscillator.connect(gain).connect(context.destination); oscillator.start(startAt); oscillator.stop(startAt + 0.15); } function render(options = {}) { const projection = createProjection(); renderCityTileLayer(projection); renderGlobe(projection, options); renderWorld(projection, options); elements.highlightLayer.replaceChildren(); renderSpecialBoundary(projection, options); renderCities(projection, options); renderSpecialLabels(projection); renderSpecialBooks(projection); syncSpecialBackButton(); renderBookshelf(); syncTooltip(projection, options); } function applyTheme(options = {}) { document.documentElement.dataset.theme = state.theme; elements.themeToggle.setAttribute("aria-pressed", String(state.theme === "dark")); elements.themeToggle.setAttribute("aria-label", state.theme === "dark" ? "切换亮色模式" : "切换暗色模式"); syncGlobeTextures(createProjection()); warmCitySpecialOceanTexture(); if (state.halftone.initialized) drawHalftoneFrame(performance.now(), true); syncMusicTrackForTheme(options); } function setupHalftoneCanvas() { if (!elements.halftoneCanvas || state.halftone.reducedMotion) return; if (state.halftone.initialized) return; state.halftone.initialized = true; state.halftone.context = elements.halftoneCanvas.getContext("2d", { alpha: true }); resizeHalftoneCanvas(); state.halftone.pointerX = state.halftone.width * 0.28; state.halftone.pointerY = state.halftone.height * 0.48; state.halftone.targetX = state.halftone.pointerX; state.halftone.targetY = state.halftone.pointerY; drawHalftoneFrame(performance.now(), true); document.documentElement.classList.add("is-halftone-ready"); elements.mapStage.addEventListener("pointermove", (event) => { const rect = elements.mapStage.getBoundingClientRect(); state.halftone.targetX = event.clientX - rect.left; state.halftone.targetY = event.clientY - rect.top; state.halftone.pulse = 1; state.halftone.activeUntil = performance.now() + HALFTONE_ACTIVE_AFTER_POINTER_MS; scheduleHalftoneFrame(); }, { passive: true }); elements.mapStage.addEventListener("pointerleave", () => { state.halftone.pulse = Math.min(state.halftone.pulse, 0.42); scheduleHalftoneFrame(); }, { passive: true }); window.addEventListener("resize", () => { scheduleHalftoneResize(); }, { passive: true }); document.addEventListener("visibilitychange", () => { if (!document.hidden) { state.halftone.activeUntil = performance.now() + HALFTONE_ACTIVE_AFTER_POINTER_MS; scheduleHalftoneFrame(); } }); } function resizeHalftoneCanvas() { const canvas = elements.halftoneCanvas; if (!canvas) return false; const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(1, Math.round(rect.width)); const height = Math.max(1, Math.round(rect.height)); if (canvas.width === Math.round(width * dpr) && canvas.height === Math.round(height * dpr)) return false; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); state.halftone.width = width; state.halftone.height = height; state.halftone.dpr = dpr; state.halftone.context?.setTransform(dpr, 0, 0, dpr, 0, 0); return true; } function scheduleHalftoneResize() { if (state.halftone.resizeTimer) window.clearTimeout(state.halftone.resizeTimer); state.halftone.resizeTimer = window.setTimeout(() => { state.halftone.resizeTimer = null; if (resizeHalftoneCanvas()) drawHalftoneFrame(performance.now(), true); }, 80); } function scheduleHalftoneFrame() { if (state.halftone.frame || state.halftone.reducedMotion) return; state.halftone.frame = requestAnimationFrame((time) => { state.halftone.frame = null; drawHalftoneFrame(time); }); } function drawHalftoneFrame(time, force = false) { const context = state.halftone.context; if (!context || state.halftone.reducedMotion) return; if (document.hidden) { state.halftone.lastDrawAt = 0; return; } const { width, height } = state.halftone; if (!width || !height) return; if (state.activeSpecialId) { if (force || state.halftone.lastDrawAt) context.clearRect(0, 0, width, height); state.halftone.lastDrawAt = 0; return; } const pointerDelta = Math.hypot( state.halftone.targetX - state.halftone.pointerX, state.halftone.targetY - state.halftone.pointerY, ); const isActive = time <= state.halftone.activeUntil || state.halftone.pulse > 0.02 || pointerDelta > HALFTONE_SETTLE_EPSILON; const frameMs = HALFTONE_ACTIVE_FRAME_MS; if (!force && state.halftone.lastDrawAt && time - state.halftone.lastDrawAt < frameMs) { if (isActive) scheduleHalftoneFrame(); return; } state.halftone.lastDrawAt = time; context.clearRect(0, 0, width, height); state.halftone.pointerX += (state.halftone.targetX - state.halftone.pointerX) * 0.14; state.halftone.pointerY += (state.halftone.targetY - state.halftone.pointerY) * 0.14; const pointerX = state.halftone.pointerX || width * 0.34; const pointerY = state.halftone.pointerY || height * 0.45; const pulse = state.halftone.pulse; const radius = Math.min(width, height) * 0.25; const radius2 = radius * radius; const dotColor = [255, 255, 255]; const ambientDriftX = Math.sin(time * 0.00022) * width * 0.14; const ambientDriftY = Math.cos(time * 0.00018) * height * 0.1; const ambientFocusX = width * 0.24 + ambientDriftX; const ambientFocusY = height * 0.52 + ambientDriftY; const tide = 0.5 + 0.5 * Math.sin(time * 0.00062); for (let y = -HALFTONE_STEP; y <= height + HALFTONE_STEP; y += HALFTONE_STEP) { const rowOffset = Math.round(y / HALFTONE_STEP) % 2 ? HALFTONE_STEP * 0.48 : 0; for (let x = -HALFTONE_STEP; x <= width + HALFTONE_STEP; x += HALFTONE_STEP) { const px = x + rowOffset; const dx = px - pointerX; const dy = y - pointerY; const distance2 = dx * dx + dy * dy; const mouseInfluence = Math.max(0, 1 - distance2 / radius2) ** 2 * pulse; const ambientDx = px - ambientFocusX; const ambientDy = y - ambientFocusY; const ambientDistance = Math.hypot(ambientDx, ambientDy); const ambientWave = 0.5 + 0.5 * Math.sin(ambientDistance * 0.024 - time * 0.0017); const flowWave = 0.5 + 0.5 * Math.sin(px * 0.016 + y * 0.028 + time * 0.00145 + Math.sin(y * 0.010 + time * 0.00042) * 2.5); const eddyWave = 0.5 + 0.5 * Math.sin(px * 0.024 - y * 0.019 + time * 0.00165 + Math.sin((px + y) * 0.008 + time * 0.00086) * 3.1); const mouseWave = 0.5 + 0.5 * Math.sin(Math.sqrt(distance2) * 0.052 - time * 0.0042); const wave = (flowWave * 0.58 + eddyWave * 0.42) * (1 - mouseInfluence) + mouseWave * mouseInfluence; const diagonal = 0.5 + 0.5 * Math.sin((px + y) * 0.018 - time * 0.00055); const dotRadius = 0.58 + diagonal * 0.22 + ambientWave * 0.42 + wave * 0.62 + tide * 0.22 + mouseInfluence * 2.45; const driftX = Math.sin(y * 0.038 + time * 0.00172 + eddyWave * 2.2) * (0.72 + wave * 0.82 + mouseInfluence * 1.2); const driftY = Math.cos(px * 0.032 + time * 0.00134 + flowWave * 1.9) * (0.52 + ambientWave * 0.58 + mouseInfluence * 0.76); context.beginPath(); context.fillStyle = `rgb(${dotColor[0]}, ${dotColor[1]}, ${dotColor[2]})`; context.arc(px + driftX, y + driftY, dotRadius, 0, Math.PI * 2); context.fill(); } } state.halftone.pulse *= 0.88; const remainingDelta = Math.hypot( state.halftone.targetX - state.halftone.pointerX, state.halftone.targetY - state.halftone.pointerY, ); if (time <= state.halftone.activeUntil || state.halftone.pulse > 0.02 || remainingDelta > HALFTONE_SETTLE_EPSILON) { scheduleHalftoneFrame(); } } function renderGlobe(projection, options = {}) { const fragment = document.createDocumentFragment(); syncGlobeTextures(projection); fragment.append( createSvg("circle", { cx: projection.cx, cy: projection.cy, r: projection.radius, class: "globe-ocean", }), ); fragment.append( createSvg("circle", { cx: projection.cx, cy: projection.cy, r: projection.radius, class: "globe-wave", }), ); fragment.append( createSvg("circle", { cx: projection.cx, cy: projection.cy, r: Math.max(0, projection.radius - 3), class: "globe-inner-softening", }), ); for (const path of graticulePaths(projection)) { fragment.append( createSvg("path", { d: path, class: "globe-graticule", }), ); } fragment.append( createSvg("circle", { cx: projection.cx, cy: projection.cy, r: projection.radius, class: "globe-rim", }), ); elements.globeLayer.replaceChildren(fragment); } function syncGlobeTextures(projection) { const cityMap = activeSpecial()?.cityMap; const landTile = cityMap ? 138 : (state.activeSpecialId ? 210 : 260); const wrapLon = ((((projection.centerLon % 360) + 360) % 360) / 360); const clampLat = Math.max(-75, Math.min(75, projection.centerLat)); const texturePhase = toRadians(projection.centerLon * 1.7 + projection.centerLat * 0.9); const landX = cityMap ? 0 : -wrapLon * landTile * 0.9; const landY = cityMap ? 0 : (clampLat / 180) * landTile * 0.38 + Math.sin(texturePhase) * 0.7; const textureRotate = cityMap ? 0 : -projection.centerLon * 0.035; const landTransform = `translate(${landX.toFixed(2)} ${landY.toFixed(2)}) rotate(${textureRotate.toFixed(2)})`; elements.landTexture?.setAttribute("width", `${landTile}`); elements.landTexture?.setAttribute("height", `${landTile}`); elements.landTexture?.setAttribute("patternTransform", landTransform); elements.landTextureImage?.setAttribute("width", `${landTile}`); elements.landTextureImage?.setAttribute("height", `${landTile}`); const oceanTile = state.activeSpecialId ? CITY_OCEAN_TEXTURE_TILE : GLOBAL_OCEAN_TEXTURE_TILE; const oceanX = cityMap ? 0 : -wrapLon * oceanTile * 1.18; const oceanY = cityMap ? 0 : (clampLat / 180) * oceanTile * 0.42 + Math.sin(texturePhase * 1.16) * 1.4; const oceanRotate = cityMap ? -4 : -projection.centerLon * 0.045; const oceanTransform = `translate(${oceanX.toFixed(2)} ${oceanY.toFixed(2)}) rotate(${oceanRotate.toFixed(2)})`; const oceanTextureHref = citySpecialOceanTextureHref(); elements.oceanWaves?.setAttribute("width", `${oceanTile}`); elements.oceanWaves?.setAttribute("height", `${oceanTile}`); elements.oceanWaves?.setAttribute("patternTransform", oceanTransform); elements.oceanWaveImage?.setAttribute("href", oceanTextureHref); elements.oceanWaveImage?.setAttribute("width", `${oceanTile}`); elements.oceanWaveImage?.setAttribute("height", `${oceanTile}`); } function citySpecialOceanTextureHref(theme = state.theme) { return theme === "dark" ? "public/textures/ocean-wave-lines-white-alpha.png" : "public/textures/ocean-wave-lines-alpha.png"; } function renderWorld(projection, options = {}) { const fragment = document.createDocumentFragment(); const special = activeSpecial(); const boundary = special ? mapData.specialBoundaries.get(special.cityId) : null; const visualBoundaryFeatures = boundary?.visualFeatures || []; const hasVisualBoundary = Boolean(visualBoundaryFeatures.length); const useRegionalChina = Boolean(special?.city?.isoA3 === "CHN" && mapData.chinaFeatures.length && !hasVisualBoundary); const features = useRegionalChina ? mapData.chinaFeatures : mapData.worldFeatures; const landEntries = []; for (const worldFeature of features) { const path = featureEntryToPath(worldFeature, projection, options); if (!path) continue; if (special?.hiddenWorldIsoA3?.includes(featureIsoA3(worldFeature.feature))) continue; const shade = useRegionalChina ? "land-strong" : (continentShade[worldFeature.feature.properties.CONTINENT] || "land-faint"); const classes = ["country", shade]; if (useRegionalChina) classes.push("country--regional"); const isSpecialFeature = isSpecialMapFeature(worldFeature.feature, special, boundary, useRegionalChina); if (isSpecialFeature && visualBoundaryFeatures.length) continue; if (isSpecialFeature) { classes.push("is-special-country"); } landEntries.push({ worldFeature, path, classes }); } const labelEntries = []; for (const entry of landEntries) { if (!special) { const label = countryLabelEntry(entry.worldFeature, projection); if (label) labelEntries.push({ entry, label }); } } const renderKeys = [ ...landEntries.map((entry) => `land:${entry.worldFeature.id || featureIsoA3(entry.worldFeature.feature)}`), ...labelEntries.map(({ entry }) => `label:${entry.worldFeature.id || featureIsoA3(entry.worldFeature.feature)}`), ].join("|"); if (state.worldRenderKeys === renderKeys && elements.worldLayer.childNodes.length === landEntries.length * 3 + labelEntries.length) { const children = Array.from(elements.worldLayer.children); landEntries.forEach((entry, index) => { const shadow = children[index]; const country = children[landEntries.length + index * 2]; const texture = children[landEntries.length + index * 2 + 1]; shadow.setAttribute("d", entry.path); country.setAttribute("d", entry.path); texture.setAttribute("d", entry.path); }); labelEntries.forEach(({ label }, index) => { const text = children[landEntries.length * 3 + index]; text.setAttribute("x", label.x.toFixed(2)); text.setAttribute("y", label.y.toFixed(2)); }); return; } for (const entry of landEntries) { fragment.append( createSvg("path", { d: entry.path, class: "country-shadow", }), ); } for (const entry of landEntries) { fragment.append( createSvg("path", { d: entry.path, class: entry.classes.join(" "), }), ); fragment.append( createSvg("path", { d: entry.path, class: "country-texture", }), ); } if (!special) { for (const { label } of labelEntries) { fragment.append( createSvg("text", { x: label.x.toFixed(2), y: label.y.toFixed(2), class: `country-label country-label--rank-${label.rank}`, "text-anchor": "middle", "dominant-baseline": "middle", }, label.name), ); } } state.worldRenderKeys = renderKeys; elements.worldLayer.replaceChildren(fragment); } function renderSpecialBoundary(projection, options = {}) { const special = activeSpecial(); if (!special) return; const fragment = document.createDocumentFragment(); if (!special.cityMap || special.useLocalContext) { const context = mapData.specialContexts.get(special.cityId); for (const contextFeature of context?.features || []) { const path = featureEntryToPath(contextFeature, projection, options); if (!path) continue; const kind = contextFeature.feature.properties?.kind || "line"; fragment.append( createSvg("path", { d: path, class: `special-context special-context--${kind}`, }), ); } } if (special.showBoundary === false) { elements.highlightLayer.replaceChildren(fragment); return; } const boundary = mapData.specialBoundaries.get(special.cityId); const matchedFeatures = specialBoundaryDisplayFeatures(special, boundary); const paths = matchedFeatures .map((worldFeature) => featureEntryToPath(worldFeature, projection, options)) .filter(Boolean); if (paths.length) { fragment.append( createSvg("path", { d: paths.join(" "), class: "special-boundary", }), ); } elements.highlightLayer.replaceChildren(fragment); } function renderCityTileLayer(projection) { const special = activeSpecial(); if (!elements.cityTileLayer) return; if ( !special?.cityMap || state.zoom < Math.max(18, special.focusZoom * 0.38) ) { if ( !elements.cityTileLayer.hidden || elements.cityTileLayer.childNodes.length || elements.cityTileLayer.classList.contains("is-loading") || elements.cityTileLayer.classList.contains("is-ready") || elements.cityTileLayer.dataset.tileRenderKey ) { elements.cityTileLayer.replaceChildren(); } elements.cityTileLayer.hidden = true; elements.cityTileLayer.classList.remove("is-loading", "is-ready"); delete elements.cityTileLayer.dataset.tileRenderKey; return; } elements.cityTileLayer.hidden = false; const stageRect = elements.mapStage.getBoundingClientRect(); const layout = cityTileLayoutForSpecial(special, stageRect, state.center); if (!layout) return; const { tileProvider, tileSize, cityMapScale, worldSize, topLeft, tileRenderKey, tiles, } = layout; if ( elements.cityTileLayer.dataset.tileRenderKey === tileRenderKey && elements.cityTileLayer.querySelector(".city-tile-layer__tiles") ) { elements.cityTileLayer.style.setProperty("--tile-center-x", `${projection.cx}px`); elements.cityTileLayer.style.setProperty("--tile-center-y", `${projection.cy}px`); if (elements.cityTileLayer.classList.contains("is-ready")) { markCitySpecialAssetReady(special, "tiles"); } return; } elements.cityTileLayer.dataset.tileRenderKey = tileRenderKey; elements.cityTileLayer.style.setProperty("--city-map-scale", String(cityMapScale)); elements.cityTileLayer.classList.remove("is-ready"); elements.cityTileLayer.classList.add("is-loading"); const fragment = document.createDocumentFragment(); const tileFragment = document.createDocumentFragment(); const tilesLayer = document.createElement("div"); tilesLayer.className = "city-tile-layer__tiles"; const tileImages = []; for (const tile of tiles) { warmCityTileImage(tile.url, "high"); const img = document.createElement("img"); img.className = "city-tile-layer__tile"; img.alt = ""; img.crossOrigin = "anonymous"; img.decoding = "async"; img.loading = "eager"; if ("fetchPriority" in img) img.fetchPriority = "high"; img.draggable = false; img.src = tile.url; img.style.width = `${tileSize}px`; img.style.height = `${tileSize}px`; // Keep each source tile on the device-pixel grid. Fractional tile origins // are visibly resampled on scaled/external displays. img.style.left = `${Math.round(tile.left)}px`; img.style.top = `${Math.round(tile.top)}px`; tileImages.push(img); tileFragment.append(img); } tilesLayer.append(tileFragment); const oceanOverlay = document.createElement("div"); oceanOverlay.className = "city-tile-layer__ocean-overlay"; oceanOverlay.classList.add("is-ready"); fragment.append(oceanOverlay, tilesLayer); const attribution = document.createElement("span"); attribution.className = "city-tile-layer__attribution"; attribution.textContent = tileProvider.attribution; fragment.append(attribution); elements.cityTileLayer.style.setProperty("--tile-center-x", `${projection.cx}px`); elements.cityTileLayer.style.setProperty("--tile-center-y", `${projection.cy}px`); elements.cityTileLayer.style.setProperty("--tile-world-size", `${worldSize}px`); elements.cityTileLayer.replaceChildren(fragment); let pendingTiles = tileImages.length; const markTileReady = () => { if (elements.cityTileLayer.dataset.tileRenderKey !== tileRenderKey) return; pendingTiles -= 1; if (pendingTiles > 0) return; elements.cityTileLayer.classList.remove("is-loading"); elements.cityTileLayer.classList.add("is-ready"); markCitySpecialAssetReady(special, "tiles"); }; if (!pendingTiles) { markTileReady(); return; } tileImages.forEach((img) => removeCityTileAdminBoundaryPixels(img, markTileReady)); } function cityTileLayoutForSpecial(special, stageRect, center = state.center) { if (!special?.cityMap) return null; const width = Math.max(1, Math.round(stageRect?.width || window.innerWidth || 1)); const height = Math.max(1, Math.round(stageRect?.height || window.innerHeight || 1)); const tileProvider = resolveCityTileProvider(special, currentTileRuntimeConfig()); const tileZoom = special.tileZoom || 12; const tileSize = tileProvider.tileSize || 256; const cityMapScale = Number(special.cityMapScale) > 0 ? Number(special.cityMapScale) : 1; const centerPixel = mercatorPixel(center, tileZoom, tileSize); const topLeft = { x: centerPixel.x - width / 2, y: centerPixel.y - height / 2, }; const tileCount = 2 ** tileZoom; const worldSize = tileSize * tileCount; const minX = Math.floor(topLeft.x / tileSize) - 1; const maxX = Math.floor((topLeft.x + width) / tileSize) + 1; const minY = Math.max(0, Math.floor(topLeft.y / tileSize) - 1); const maxY = Math.min(tileCount - 1, Math.floor((topLeft.y + height) / tileSize) + 1); const tiles = []; for (let tileY = minY; tileY <= maxY; tileY += 1) { for (let tileX = minX; tileX <= maxX; tileX += 1) { const wrappedX = ((tileX % tileCount) + tileCount) % tileCount; const url = buildCityTileUrl(tileProvider, { z: tileZoom, x: wrappedX, y: tileY }); if (!url) continue; tiles.push({ url, left: tileX * tileSize - topLeft.x, top: tileY * tileSize - topLeft.y, }); } } return { tileProvider, tileZoom, tileSize, cityMapScale, worldSize, topLeft, tiles, tileRenderKey: [ special.id, tileProvider.name, tileZoom, tileSize, cityMapScale, width, height, minX, maxX, minY, maxY, topLeft.x.toFixed(2), topLeft.y.toFixed(2), ].join("|"), }; } function warmCitySpecialTileImages(special, priority = "low") { if (!special?.cityMap) return; const stageRect = elements.mapStage?.getBoundingClientRect?.(); const layout = cityTileLayoutForSpecial(special, stageRect, specialViewCenter(special)); if (!layout) return; layout.tiles.forEach((tile) => warmCityTileImage(tile.url, priority)); } function warmCityTileImage(url, priority = "low") { if (!url) return null; const existing = cityTileImagePreloadCache.get(url); const now = performance.now(); if (existing) { existing.lastUsedAt = now; if (priority === "high" && existing.image && "fetchPriority" in existing.image) { existing.image.fetchPriority = "high"; } return existing.promise; } const image = new Image(); image.crossOrigin = "anonymous"; image.decoding = "async"; image.loading = "eager"; if ("fetchPriority" in image) image.fetchPriority = priority; const entry = { image, status: "loading", lastUsedAt: now, promise: null, }; let settled = false; entry.promise = new Promise((resolve) => { const finish = (status) => { if (settled) return; settled = true; entry.status = status; entry.lastUsedAt = performance.now(); resolve(entry); }; image.addEventListener("load", () => { if (typeof image.decode === "function") { image.decode().then(() => finish("ready")).catch(() => finish("ready")); } else { finish("ready"); } }, { once: true }); image.addEventListener("error", () => finish("failed"), { once: true }); image.src = url; if (image.complete) finish(image.naturalWidth > 0 ? "ready" : "failed"); }); cityTileImagePreloadCache.set(url, entry); pruneCityTileImagePreloadCache(); return entry.promise; } function pruneCityTileImagePreloadCache() { if (cityTileImagePreloadCache.size <= CITY_TILE_PRELOAD_LIMIT) return; const removable = Array.from(cityTileImagePreloadCache.entries()) .filter(([, entry]) => entry.status !== "loading") .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt); while (cityTileImagePreloadCache.size > CITY_TILE_PRELOAD_LIMIT && removable.length) { cityTileImagePreloadCache.delete(removable.shift()[0]); } } function cacheCityTilePixels(sourceUrl, filteredSource) { if (cityTilePixelCache.has(sourceUrl)) cityTilePixelCache.delete(sourceUrl); while (cityTilePixelCache.size >= CITY_TILE_PIXEL_CACHE_LIMIT) { const oldestKey = cityTilePixelCache.keys().next().value; if (oldestKey == null) break; cityTilePixelCache.delete(oldestKey); } cityTilePixelCache.set(sourceUrl, filteredSource); } function settleCityTileImageWhenDecoded(img, onSettled) { let settled = false; const settle = () => { if (settled) return; settled = true; onSettled?.(); }; const finish = () => requestAnimationFrame(settle); if (typeof img.decode === "function" && img.complete && img.naturalWidth) { img.decode().then(finish).catch(finish); return; } finish(); } function removeCityTileAdminBoundaryPixels(img, onSettled) { if (!img) { onSettled?.(); return; } const sourceUrl = img.src; const settle = () => settleCityTileImageWhenDecoded(img, onSettled); if (img.dataset.adminBoundaryFiltered === "done" || img.dataset.adminBoundaryFiltered === "failed") { settle(); return; } if (cityTilePixelCache.has(sourceUrl)) { const cachedSource = cityTilePixelCache.get(sourceUrl); img.dataset.adminBoundaryFiltered = "done"; if (!cachedSource) { if (img.complete) { settle(); } else { const finishCachedOriginal = () => settle(); img.addEventListener("load", finishCachedOriginal, { once: true }); img.addEventListener("error", finishCachedOriginal, { once: true }); } return; } const finishCachedImage = () => settle(); img.addEventListener("load", finishCachedImage, { once: true }); img.addEventListener("error", finishCachedImage, { once: true }); img.src = cachedSource; return; } const applyFilter = () => { if (img.dataset.adminBoundaryFiltered === "done" || img.dataset.adminBoundaryFiltered === "failed") { settle(); return; } if (!img.naturalWidth || !img.naturalHeight) { img.dataset.adminBoundaryFiltered = "failed"; settle(); return; } try { const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) throw new Error("city tile pixel context unavailable"); context.drawImage(img, 0, 0); const imageData = context.getImageData(0, 0, canvas.width, canvas.height); const { data } = imageData; let changed = false; for (let index = 0; index < data.length; index += 4) { if (isFaintCityTileOceanResiduePixel(data[index], data[index + 1], data[index + 2], data[index + 3])) { data[index + 3] = 0; changed = true; continue; } if (isLikelyAdminBoundaryPixel(data[index], data[index + 1], data[index + 2], data[index + 3])) { data[index + 3] = 0; changed = true; continue; } const mutedGreen = isLikelyBasemapGreenAreaPixel(data[index], data[index + 1], data[index + 2], data[index + 3]) ? mutedCityTileGreenPixel(data[index], data[index + 1], data[index + 2]) : null; if (mutedGreen) { data[index] = mutedGreen[0]; data[index + 1] = mutedGreen[1]; data[index + 2] = mutedGreen[2]; changed = true; } } changed = paintCityTileCoastShadowPixels(data, canvas.width, canvas.height) || changed; img.dataset.adminBoundaryFiltered = "done"; if (!changed) { cacheCityTilePixels(sourceUrl, ""); settle(); return; } context.putImageData(imageData, 0, 0); const filteredSource = canvas.toDataURL("image/png"); cacheCityTilePixels(sourceUrl, filteredSource); const finishFilteredImage = () => settle(); img.addEventListener("load", finishFilteredImage, { once: true }); img.addEventListener("error", finishFilteredImage, { once: true }); img.src = filteredSource; } catch { img.dataset.adminBoundaryFiltered = "failed"; settle(); } }; if (img.complete && img.naturalWidth) { applyFilter(); return; } img.addEventListener("load", applyFilter, { once: true }); img.addEventListener("error", () => { img.dataset.adminBoundaryFiltered = "failed"; settle(); }, { once: true }); } function isFaintCityTileOceanResiduePixel(r, g, b, a) { return a > 0 && a <= 96 && b >= r + 28 && b >= g + 18; } function paintCityTileCoastShadowPixels(data, width, height) { const alpha = new Uint8ClampedArray(width * height); for (let index = 0, pixel = 0; index < data.length; index += 4, pixel += 1) { alpha[pixel] = data[index + 3]; } const shadowAlpha = new Uint8ClampedArray(width * height); let hasShadow = false; for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const pixel = y * width + x; if (alpha[pixel] < 72 || !cityTileLandPixelTouchesOcean(alpha, width, height, x, y)) continue; for (let dy = -CITY_TILE_COAST_SHADOW_RADIUS; dy <= CITY_TILE_COAST_SHADOW_RADIUS; dy += 1) { const targetY = y + dy; if (targetY < 0 || targetY >= height) continue; for (let dx = -CITY_TILE_COAST_SHADOW_RADIUS; dx <= CITY_TILE_COAST_SHADOW_RADIUS; dx += 1) { const targetX = x + dx; if (targetX < 0 || targetX >= width) continue; const distance = Math.hypot(dx, dy); if (distance <= 0 || distance > CITY_TILE_COAST_SHADOW_RADIUS) continue; const targetPixel = targetY * width + targetX; if (alpha[targetPixel] >= 24) continue; const strength = cityTileCoastShadowStrength(distance); if (strength > shadowAlpha[targetPixel]) shadowAlpha[targetPixel] = strength; } } } } for (let pixel = 0; pixel < shadowAlpha.length; pixel += 1) { const shadow = shadowAlpha[pixel]; if (!shadow) continue; const index = pixel * 4; data[index] = CITY_TILE_COAST_SHADOW_COLOR[0]; data[index + 1] = CITY_TILE_COAST_SHADOW_COLOR[1]; data[index + 2] = CITY_TILE_COAST_SHADOW_COLOR[2]; data[index + 3] = Math.max(data[index + 3], shadow); hasShadow = true; } return hasShadow; } function cityTileCoastShadowStrength(distance) { const fade = 1 - distance / (CITY_TILE_COAST_SHADOW_RADIUS + 1); return Math.round((Math.max(0, fade) ** CITY_TILE_COAST_SHADOW_FALLOFF) * CITY_TILE_COAST_SHADOW_MAX_ALPHA); } function cityTileLandPixelTouchesOcean(alpha, width, height, x, y) { for (let dy = -1; dy <= 1; dy += 1) { const targetY = y + dy; if (targetY < 0 || targetY >= height) continue; for (let dx = -1; dx <= 1; dx += 1) { if (dx === 0 && dy === 0) continue; const targetX = x + dx; if (targetX < 0 || targetX >= width) continue; if (alpha[targetY * width + targetX] < 24) return true; } } return false; } function isLikelyAdminBoundaryPixel(r, g, b, a) { if (a < 16) return false; if (isPaleRedAdministrativeBoundaryPixel(r, g, b)) return true; if (isLikelyBasemapRedLinePixel(r, g, b)) return true; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const lightness = (r + g + b) / 3; const saturation = max ? (max - min) / max : 0; return lightness > 125 && lightness < 245 && saturation > 0.07 && saturation < 0.32 && r > g + 5 && r > b + 12 && g >= b - 8 && g - b <= 22; } function isLikelyBasemapRedLinePixel(r, g, b) { const max = Math.max(r, g, b); const min = Math.min(r, g, b); const lightness = (r + g + b) / 3; const saturation = max ? (max - min) / max : 0; const redBias = r - g; const blueBias = r - b; const greenBlueGap = g - b; return lightness > 72 && lightness < 248 && saturation > 0.025 && saturation < 0.64 && redBias >= 2 && blueBias >= 6 && greenBlueGap >= -22 && greenBlueGap <= 48; } function isPaleRedAdministrativeBoundaryPixel(r, g, b) { const max = Math.max(r, g, b); const min = Math.min(r, g, b); const lightness = (r + g + b) / 3; const redBias = r - g; const greenBias = g - b; const blueBias = r - b; return lightness > 150 && lightness < 252 && max - min <= 46 && redBias >= 5 && redBias <= 32 && greenBias >= 2 && greenBias <= 18 && blueBias >= 10 && blueBias <= 44; } function isLikelyBasemapGreenAreaPixel(r, g, b, a) { if (a < 16) return false; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const lightness = (r + g + b) / 3; const saturation = max ? (max - min) / max : 0; const greenBias = g - r; const blueGreenGap = g - b; return lightness > 72 && lightness < 236 && saturation > 0.07 && saturation < 0.58 && greenBias >= 10 && blueGreenGap <= 22 && b >= r - 18; } function mutedCityTileGreenPixel(r, g, b) { const lightness = (r + g + b) / 3; const warmStone = clamp(156 + (lightness - 150) * 0.32, 108, 214); return [ Math.round(warmStone + 14), Math.round(warmStone + 8), Math.round(warmStone - 8), ]; } function mercatorPixel([lon, lat], zoom, tileSize = 256) { const scale = tileSize * 2 ** zoom; const clampedLat = clamp(lat, -85.05112878, 85.05112878); const sinLat = Math.sin(toRadians(clampedLat)); return { x: ((lon + 180) / 360) * scale, y: (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale, }; } function specialBoundaryDisplayFeatures(special, boundary) { const visualFeatures = boundary?.visualFeatures || []; if (visualFeatures.length) return visualFeatures; const baseFeatures = currentSpecialBaseFeatures(special, boundary); if (baseFeatures.length) return baseFeatures; if (boundary?.outlineFeatures?.length) return boundary.outlineFeatures; return boundary?.features || []; } function currentSpecialBaseFeatures(special, boundary) { if (!special) return []; const useRegionalChina = Boolean(special.city?.isoA3 === "CHN" && mapData.chinaFeatures.length); const features = useRegionalChina ? mapData.chinaFeatures : mapData.worldFeatures; return features.filter((worldFeature) => isSpecialMapFeature(worldFeature.feature, special, boundary, useRegionalChina)); } function renderSpecialLabels(projection) { if (state.activeSpecialId) { for (const entry of specialLabelCache.values()) entry.element.hidden = true; return; } const visibleSpecialIds = new Set(); for (const special of mapData.specials) { const [lon, lat] = cityCoordinates(special.city); const [x, y, visible] = projectPoint([lon, lat], projection); if (!visible) continue; visibleSpecialIds.add(special.id); const point = svgToStageFreePoint(x, y); const entry = specialLabelCache.get(special.id) || createSpecialLabel(special); const [offsetX, offsetY] = special.labelOffset || [18, -56]; entry.element.hidden = false; entry.element.style.setProperty("--special-x", `${(point.x + offsetX).toFixed(2)}px`); entry.element.style.setProperty("--special-y", `${(point.y + offsetY).toFixed(2)}px`); if (entry.element.parentNode !== elements.specialLabelLayer) { elements.specialLabelLayer.append(entry.element); } } for (const [specialId, entry] of specialLabelCache.entries()) { entry.element.hidden = !visibleSpecialIds.has(specialId); } } function createSpecialLabel(special) { const button = document.createElement("button"); button.className = "special-capsule"; button.type = "button"; const entryName = special.entryName || special.specialName; button.setAttribute("aria-label", `打开 ${entryName}`); const mark = createSpecialEntryMark(special.id); const title = document.createElement("span"); title.className = "special-capsule__title"; title.textContent = entryName; button.append(mark, title); button.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); button.addEventListener("pointerenter", () => warmCitySpecialMapAssets(special, "high")); button.addEventListener("focus", () => warmCitySpecialMapAssets(special, "high")); button.addEventListener("click", (event) => { event.stopPropagation(); warmCitySpecialMapAssets(special, "high"); playUiSound("bookmarkOpen"); enterSpecialView(special.id); }); const entry = { element: button, title }; specialLabelCache.set(special.id, entry); return entry; } function renderSpecialBooks(projection) { const special = activeSpecial(); if (!special) { clearSpecialBookLayers(); return; } const stageRect = elements.mapStage.getBoundingClientRect(); prepareSpecialOverlay(stageRect); renderActiveSpecialBooks(special, projection, stageRect); } function clearSpecialBookLayers() { if (elements.specialOverlaySvg.childNodes.length) elements.specialOverlaySvg.replaceChildren(); if (elements.specialBookLayer.childNodes.length) elements.specialBookLayer.replaceChildren(); elements.specialOverlaySvg.classList.remove("is-loading"); elements.specialOverlaySvg.classList.remove("is-ready"); elements.specialBookLayer.classList.remove("is-loading"); elements.specialBookLayer.classList.remove("is-ready"); state.specialStackRenderData.clear(); state.specialBookRenderData.clear(); } function renderActiveSpecialBooks(special, projection, stageRect) { const [lon, lat] = cityCoordinates(special.city); const [x, y, visible] = projectPoint([lon, lat], projection); if (!visible) { elements.specialOverlaySvg.replaceChildren(); elements.specialBookLayer.replaceChildren(); elements.specialOverlaySvg.classList.remove("is-loading"); elements.specialOverlaySvg.classList.remove("is-ready"); elements.specialBookLayer.classList.remove("is-loading"); elements.specialBookLayer.classList.remove("is-ready"); state.specialStackRenderData.clear(); state.specialBookRenderData.clear(); return; } const origin = svgToStageFreePoint(x, y); const books = special.books.length ? special.books : [curatingPlaceholderBook(special)]; const bookEntries = spreadSpecialBookEntries(special, books); if (special.cityMap) { const readyToken = beginSpecialBookLayerLoad(special); const visibleEntries = orderCityMapBookEntries(bookEntries, special).slice(0, SPECIAL_BOOK_OFFSETS.length); const layoutSpecial = { ...special, cityMapSpreadRows: specialBookSpreadRows(visibleEntries), }; const hasCompleteManualLayout = hasCompleteCityMapSpecialBookLayout(special, visibleEntries); const defaultBookPoints = applyCityMapSpecialBookLayout( special, visibleEntries, hasCompleteManualLayout ? visibleEntries.map(() => ({ x: 0, y: 0 })) : resolveSpecialBookPoints( visibleEntries.map((entry, index) => specialBookPoint(origin, index, stageRect, layoutSpecial, "book")), origin, stageRect, layoutSpecial, "book", ), stageRect, ); const bookPoints = visibleEntries.map((entry, index) => ( state.specialBookDraftPoints.get(specialBookDraftKey(special.id, entry.book.id)) || defaultBookPoints[index] )); const lineFragment = document.createDocumentFragment(); const bookFragment = document.createDocumentFragment(); const nextBookRenderData = new Map(); visibleEntries.forEach((entry, index) => { const point = bookPoints[index]; const hoverKey = specialBookKey(special.id, entry.sourceIndex); const anchor = specialBookAnchor(special, entry.book, origin, entry.sourceIndex, stageRect, projection); lineFragment.append( createSvg("path", { d: specialConnectorPath(anchor, point, index, cityMapConnectorPathOptions(point, stageRect)), class: "special-book-line", "data-book-key": hoverKey, style: `--book-delay: ${Math.min(index * 34, 260)}ms`, }), createSpecialBookAnchorElement(anchor, hoverKey, index), ); const bookElement = createSpecialBookElement(special, entry.book, point, entry.sourceIndex, hoverKey, "special", entry.category); bookElement.style.setProperty("--book-delay", `${Math.min(index * 72, 620)}ms`); bookFragment.append(bookElement); nextBookRenderData.set(hoverKey, { special, book: entry.book, point, index, sourceIndex: entry.sourceIndex, hoverKey, anchor, stageRect, }); }); state.specialStackRenderData.clear(); state.specialBookRenderData = nextBookRenderData; elements.specialOverlaySvg.replaceChildren(lineFragment); elements.specialBookLayer.replaceChildren(bookFragment); revealSpecialBookLayerWhenReady(special, readyToken); return; } const readyToken = beginSpecialBookLayerLoad(special); const bookGroups = specialBookGroups(special, books); const lineFragment = document.createDocumentFragment(); const bookFragment = document.createDocumentFragment(); const nextStackRenderData = new Map(); const visibleGroups = bookGroups.slice(0, SPECIAL_CATEGORY_STACK_OFFSETS.length); const bookPoints = resolveSpecialBookPoints( visibleGroups.map((group, index) => specialBookPoint(origin, index, stageRect, special, "stack")), origin, stageRect, special, "stack", ); visibleGroups.forEach((group, index) => { const point = bookPoints[index]; const hoverKey = specialBookStackKey(special.id, group.category); appendSpecialBookConnectors(lineFragment, special, group, point, index, hoverKey, origin, projection, stageRect); bookFragment.append(createSpecialBookStackElement(special, group, point, index, hoverKey)); nextStackRenderData.set(hoverKey, { special, group, point, index, hoverKey, origin, projection, stageRect, }); }); state.specialStackRenderData = nextStackRenderData; state.specialBookRenderData.clear(); elements.specialOverlaySvg.replaceChildren(lineFragment); elements.specialBookLayer.replaceChildren(bookFragment); revealSpecialBookLayerWhenReady(special, readyToken); } function specialStackElementSelector(stackKey) { return `[data-stack-key="${cssAttributeValue(stackKey)}"]`; } function specialBookOverlaySelector(hoverKey, className) { const classSelector = className ? `.${className}` : ""; return `${classSelector}[data-book-key="${cssAttributeValue(hoverKey)}"]`; } function updateSpecialBookStack(stackKey) { const data = state.specialStackRenderData.get(stackKey); const current = elements.specialBookLayer.querySelector(specialStackElementSelector(stackKey)); if (!data || !current) { render(); return; } const nextStack = createSpecialBookStackElement(data.special, data.group, data.point, data.index, data.hoverKey); syncSpecialBookStackElement(current, nextStack); updateSpecialBookConnector(data); syncSpecialStackActiveClasses(); } function syncSpecialBookStackElement(current, nextStack) { current.className = nextStack.className; current.dataset.bookKey = nextStack.dataset.bookKey; current.dataset.stackKey = nextStack.dataset.stackKey; current.style.cssText = nextStack.style.cssText; const nextChildren = Array.from(nextStack.childNodes); current.replaceChildren(...nextChildren); scheduleTicketCutPaths(current); } function updateSpecialBookConnector(data) { elements.specialOverlaySvg .querySelectorAll(specialBookOverlaySelector(data.hoverKey)) .forEach((element) => element.remove()); const fragment = document.createDocumentFragment(); appendSpecialBookConnectors(fragment, data.special, data.group, data.point, data.index, data.hoverKey, data.origin, data.projection, data.stageRect); elements.specialOverlaySvg.append(fragment); } function syncSpecialStackActiveClasses() { elements.specialBookLayer.querySelectorAll(".special-book-stack").forEach((stack) => { stack.classList.toggle("is-stack-active", stack.dataset.stackKey === state.activeSpecialStackKey); }); } function beginSpecialBookLayerLoad(special) { const bookLayer = elements.specialBookLayer; const overlay = elements.specialOverlaySvg; const token = `${special.id}:${(Number(bookLayer.dataset.readyToken) || 0) + 1}`; bookLayer.dataset.readyToken = token; overlay.dataset.readyToken = token; bookLayer.classList.remove("is-ready"); overlay.classList.remove("is-ready"); bookLayer.classList.add("is-loading"); overlay.classList.add("is-loading"); return token; } function revealSpecialBookLayerWhenReady(special, token = beginSpecialBookLayerLoad(special)) { const bookLayer = elements.specialBookLayer; const overlay = elements.specialOverlaySvg; const reveal = () => { requestAnimationFrame(() => { requestAnimationFrame(() => { if (bookLayer.dataset.readyToken !== token || overlay.dataset.readyToken !== token) return; updateTicketCutPaths(bookLayer); alignCityMapSpecialBookEdges(special, false); bookLayer.classList.add("is-ready"); overlay.classList.add("is-ready"); if (!special.cityMap) { bookLayer.classList.remove("is-loading"); overlay.classList.remove("is-loading"); } markCitySpecialAssetReady(special, "books"); }); }); }; reveal(); } function beginCitySpecialAssetLoad(special) { const root = document.documentElement; if (state.citySpecialReadyTimer) { window.clearTimeout(state.citySpecialReadyTimer); state.citySpecialReadyTimer = null; } if (state.citySpecialLandRevealTimer) { window.clearTimeout(state.citySpecialLandRevealTimer); state.citySpecialLandRevealTimer = null; } root.dataset.citySpecialLoadId = special.id; root.dataset.citySpecialTilesReady = "0"; root.dataset.citySpecialBooksReady = "0"; root.dataset.citySpecialOceanReady = "0"; root.dataset.citySpecialLandRevealed = "0"; root.dataset.citySpecialOceanTransitionStarted = "0"; root.dataset.citySpecialOceanStartedAt = String(performance.now()); root.classList.remove("is-city-special-ready", "is-city-special-land-ready", "is-city-special-content-ready", "is-city-special-ocean-ready", "is-city-ocean-entering"); root.classList.add("is-special-view", "is-special-city-map", "is-city-special-loading", "is-city-ocean-preparing"); prepareCityTileLayerForLoad(); prepareCitySpecialContentLayersForLoad(); preloadCitySpecialOceanTexture(special); } function prepareCityTileLayerForLoad() { elements.cityTileLayer.replaceChildren(); elements.cityTileLayer.classList.remove("is-ready"); elements.cityTileLayer.classList.add("is-loading"); delete elements.cityTileLayer.dataset.tileRenderKey; } function prepareCitySpecialContentLayersForLoad() { elements.specialOverlaySvg.replaceChildren(); elements.specialBookLayer.replaceChildren(); elements.specialOverlaySvg.classList.remove("is-ready"); elements.specialBookLayer.classList.remove("is-ready"); elements.specialOverlaySvg.classList.add("is-loading"); elements.specialBookLayer.classList.add("is-loading"); delete elements.specialOverlaySvg.dataset.readyToken; delete elements.specialBookLayer.dataset.readyToken; state.activeSpecialStackKey = null; state.specialStackRenderData.clear(); state.specialBookRenderData.clear(); state.specialBookSuppressedClickKey = null; } function startCityOceanTransition(special) { const root = document.documentElement; requestAnimationFrame(() => { requestAnimationFrame(() => { if (root.dataset.citySpecialLoadId !== special.id) return; root.dataset.citySpecialOceanTransitionStarted = "1"; root.dataset.citySpecialOceanStartedAt = String(performance.now()); root.classList.remove("is-city-ocean-preparing"); root.classList.add("is-city-ocean-entering"); markCitySpecialAssetReady(special, "transition"); }); }); } function preloadCitySpecialOceanTexture(special) { warmCitySpecialOceanTexture(state.theme, () => { markCitySpecialAssetReady(special, "ocean"); }); } function warmCitySpecialMapAssets(special, priority = "low") { if (!special?.cityMap) return; warmCitySpecialOceanTexture(state.theme); warmCitySpecialTileImages(special, priority); } function warmCitySpecialOceanTexture(theme = state.theme, onReady) { const source = citySpecialOceanTextureHref(theme); if (citySpecialOceanTextureCache.has(source)) { onReady?.(); return; } const image = new Image(); let settled = false; const settle = () => { if (settled) return; settled = true; citySpecialOceanTextureCache.add(source); onReady?.(); }; image.decoding = "async"; image.addEventListener("load", settle, { once: true }); image.addEventListener("error", settle, { once: true }); image.src = source; if (image.complete) settle(); } function markCitySpecialAssetReady(special, part) { const root = document.documentElement; if (root.dataset.citySpecialLoadId !== special.id) return; if (part === "tiles") root.dataset.citySpecialTilesReady = "1"; if (part === "books") root.dataset.citySpecialBooksReady = "1"; if (part === "ocean") { root.dataset.citySpecialOceanReady = "1"; root.classList.add("is-city-special-ocean-ready"); } revealCitySpecialLandWhenReady(special); revealCitySpecialContentWhenReady(special); if ( root.dataset.citySpecialOceanReady !== "1" || root.dataset.citySpecialOceanTransitionStarted !== "1" || (special.cityMap && root.dataset.citySpecialTilesReady !== "1") ) return; const startedAt = Number(root.dataset.citySpecialOceanStartedAt) || performance.now(); const elapsed = performance.now() - startedAt; const remaining = Math.max(0, CITY_OCEAN_TRANSITION_MIN_MS + CITY_OCEAN_TRANSITION_SETTLE_MS - elapsed); if (remaining > 0) { if (!state.citySpecialReadyTimer) { state.citySpecialReadyTimer = window.setTimeout(() => { state.citySpecialReadyTimer = null; finishCitySpecialAssetLoad(special); }, remaining); } return; } finishCitySpecialAssetLoad(special); } function citySpecialLandRevealDelay() { return state.halftone.reducedMotion ? 0 : CITY_SPECIAL_BOOK_REVEAL_AFTER_LAND_MS; } function revealCitySpecialLandWhenReady(special) { if (!special?.cityMap) return; const root = document.documentElement; if (root.dataset.citySpecialLoadId !== special.id) return; if (root.classList.contains("is-city-special-land-ready")) return; if ( root.dataset.citySpecialTilesReady !== "1" || root.dataset.citySpecialOceanTransitionStarted !== "1" ) return; const startedAt = Number(root.dataset.citySpecialOceanStartedAt) || performance.now(); const elapsed = performance.now() - startedAt; const remaining = Math.max(0, CITY_SPECIAL_LAND_REVEAL_MIN_MS - elapsed); if (remaining > 0) { if (!state.citySpecialLandRevealTimer) { state.citySpecialLandRevealTimer = window.setTimeout(() => { state.citySpecialLandRevealTimer = null; revealCitySpecialLandWhenReady(special); }, remaining); } return; } root.classList.add("is-city-special-land-ready"); state.citySpecialLandRevealTimer = window.setTimeout(() => { state.citySpecialLandRevealTimer = null; if (root.dataset.citySpecialLoadId !== special.id) return; root.dataset.citySpecialLandRevealed = "1"; revealCitySpecialContentWhenReady(special); }, citySpecialLandRevealDelay()); } function revealCitySpecialContentWhenReady(special) { if (!special?.cityMap) return; const root = document.documentElement; if (root.dataset.citySpecialLoadId !== special.id) return; if (root.classList.contains("is-city-special-content-ready")) return; if ( root.dataset.citySpecialBooksReady !== "1" || root.dataset.citySpecialLandRevealed !== "1" ) return; root.classList.add("is-city-special-content-ready"); elements.specialOverlaySvg.classList.remove("is-loading"); elements.specialBookLayer.classList.remove("is-loading"); } function finishCitySpecialAssetLoad(special) { const root = document.documentElement; if (root.dataset.citySpecialLoadId !== special.id) return; if ( root.dataset.citySpecialOceanReady !== "1" || root.dataset.citySpecialOceanTransitionStarted !== "1" ) return; freezeCityOceanTransition(); root.classList.remove("is-city-special-loading", "is-city-ocean-preparing", "is-city-ocean-entering"); root.classList.add("is-city-special-ready"); stopCityMapLoadingSound(); revealCitySpecialLandWhenReady(special); revealCitySpecialContentWhenReady(special); } function resetCitySpecialAssetLoad() { const root = document.documentElement; if (state.citySpecialReadyTimer) { window.clearTimeout(state.citySpecialReadyTimer); state.citySpecialReadyTimer = null; } if (state.citySpecialLandRevealTimer) { window.clearTimeout(state.citySpecialLandRevealTimer); state.citySpecialLandRevealTimer = null; } delete root.dataset.citySpecialLoadId; delete root.dataset.citySpecialTilesReady; delete root.dataset.citySpecialBooksReady; delete root.dataset.citySpecialOceanReady; delete root.dataset.citySpecialLandRevealed; delete root.dataset.citySpecialOceanTransitionStarted; delete root.dataset.citySpecialOceanStartedAt; root.classList.remove("is-city-special-loading", "is-city-special-ready", "is-city-special-land-ready", "is-city-special-content-ready", "is-city-special-ocean-ready", "is-city-ocean-preparing", "is-city-ocean-entering"); stopCityMapLoadingSound(); } function captureCityOceanTransitionFromGlobe() { const projection = createProjection(); const center = svgToStageFreePoint(projection.cx, projection.cy); const edge = svgToStageFreePoint(projection.cx + projection.radius, projection.cy); const stageRadius = Math.hypot(edge.x - center.x, edge.y - center.y); const scale = projection.radius > 0 ? stageRadius / projection.radius : 1; const metrics = globeOceanTextureMetrics(projection); const offsetX = center.x - projection.cx * scale; const offsetY = center.y - projection.cy * scale; return { centerX: center.x, centerY: center.y, radius: stageRadius, textureSize: metrics.tile * scale, textureX: offsetX + metrics.x * scale, textureY: offsetY + metrics.y * scale, }; } function applyCityOceanTransitionFromGlobe(transition) { if (!elements.cityTileLayer || !transition) return; elements.cityTileLayer.style.setProperty("--city-ocean-origin-x", `${roundCssPixel(transition.centerX)}px`); elements.cityTileLayer.style.setProperty("--city-ocean-origin-y", `${roundCssPixel(transition.centerY)}px`); elements.cityTileLayer.style.setProperty("--city-ocean-start-radius", `${roundCssPixel(transition.radius)}px`); elements.cityTileLayer.style.setProperty("--city-ocean-start-size", `${roundCssPixel(transition.textureSize)}px`); elements.cityTileLayer.style.setProperty("--city-ocean-start-x", `${roundCssPixel(transition.textureX)}px`); elements.cityTileLayer.style.setProperty("--city-ocean-start-y", `${roundCssPixel(transition.textureY)}px`); elements.cityTileLayer.style.setProperty("--city-ocean-final-size", `${CITY_OCEAN_TEXTURE_TILE}px`); elements.cityTileLayer.style.setProperty("--city-ocean-final-x", "0px"); elements.cityTileLayer.style.setProperty("--city-ocean-final-y", "0px"); } function freezeCityOceanTransition() { if (!elements.cityTileLayer) return; const root = document.documentElement; const startedAt = Number(root.dataset.citySpecialOceanStartedAt) || performance.now(); const elapsed = Math.max(0, performance.now() - startedAt); const startX = cssPixelNumber(elements.cityTileLayer, "--city-ocean-start-x"); const startY = cssPixelNumber(elements.cityTileLayer, "--city-ocean-start-y"); const finalSize = CITY_OCEAN_TEXTURE_TILE; const drift = ((elapsed % CITY_OCEAN_TRANSITION_DRIFT_MS) / CITY_OCEAN_TRANSITION_DRIFT_MS) * finalSize; elements.cityTileLayer.style.setProperty("--city-ocean-final-size", `${finalSize}px`); elements.cityTileLayer.style.setProperty("--city-ocean-final-x", `${roundCssPixel(startX - drift)}px`); elements.cityTileLayer.style.setProperty("--city-ocean-final-y", `${roundCssPixel(startY)}px`); } function globeOceanTextureMetrics(projection) { const tile = GLOBAL_OCEAN_TEXTURE_TILE; const wrapLon = ((((projection.centerLon % 360) + 360) % 360) / 360); const clampLat = Math.max(-75, Math.min(75, projection.centerLat)); const texturePhase = toRadians(projection.centerLon * 1.7 + projection.centerLat * 0.9); return { tile, x: -wrapLon * tile * 1.18, y: (clampLat / 180) * tile * 0.42 + Math.sin(texturePhase * 1.16) * 1.4, }; } function cssPixelNumber(element, propertyName) { return Number.parseFloat(element.style.getPropertyValue(propertyName)) || 0; } function roundCssPixel(value) { return Math.round(value * 100) / 100; } function alignCityMapSpecialBookEdges(special, defer = true) { const alignments = CITY_MAP_SPECIAL_BOOK_EDGE_ALIGNMENTS[special?.id]; if (!alignments?.length) return; const apply = () => { alignments.forEach((alignment) => { const bookElements = alignment.ids .map((bookId) => elements.specialBookLayer.querySelector(`.special-book${bookIdSelector(bookId)}`)) .filter(Boolean); if (bookElements.length < 2) return; const rects = bookElements.map((element) => element.getBoundingClientRect()); const target = alignment.edge === "bottom" ? Math.max(...rects.map((rect) => rect.bottom)) : Math.min(...rects.map((rect) => rect.top)); bookElements.forEach((element, index) => { const rect = rects[index]; const current = alignment.edge === "bottom" ? rect.bottom : rect.top; const currentY = Number.parseFloat(element.style.getPropertyValue("--book-y")) || 0; const delta = target - current; if (Math.abs(delta) < 0.5) return; element.style.setProperty("--book-y", `${(currentY + delta).toFixed(2)}px`); }); }); }; if (defer) requestAnimationFrame(apply); else apply(); } function bookIdSelector(bookId) { return `[data-book-id="${cssAttributeValue(bookId)}"]`; } function specialBookGroups(special, books) { const groups = []; const byCategory = new Map(); books.forEach((book, sourceIndex) => { const category = specialBookCategory(book, special); if (!byCategory.has(category)) { const group = { category, categoryEn: specialBookCategoryEn(category), entries: [], }; byCategory.set(category, group); groups.push(group); } byCategory.get(category).entries.push({ book, sourceIndex }); }); return groups; } function spreadSpecialBookEntries(special, books) { return books.map((book, sourceIndex) => ({ book, sourceIndex, category: specialBookCategory(book, special), })); } function cityMapBookEntryRow(entry) { return CITY_MAP_TOP_ROW_CATEGORY_SET.has(entry?.category) ? 0 : 1; } function cityMapCategoryOrder(category, special) { const order = CITY_MAP_CATEGORY_ORDER_INDEX_BY_SPECIAL.get(special?.id) || CITY_MAP_CATEGORY_ORDER_INDEX; return order.get(category) ?? order.size; } function orderCityMapBookEntries(entries, special) { return [...entries].sort((a, b) => { const rowDelta = cityMapBookEntryRow(a) - cityMapBookEntryRow(b); if (rowDelta) return rowDelta; const categoryDelta = cityMapCategoryOrder(a.category, special) - cityMapCategoryOrder(b.category, special); if (categoryDelta) return categoryDelta; return a.sourceIndex - b.sourceIndex; }); } function specialBookSpreadRows(entries) { const topEntries = []; const bottomEntries = []; entries.forEach((entry) => { (cityMapBookEntryRow(entry) === 0 ? topEntries : bottomEntries).push(entry); }); const rows = [topEntries.length, bottomEntries.length]; rows.topGroups = cityMapEntryGroups(topEntries); rows.bottomGroups = cityMapEntryGroups(bottomEntries); return rows; } function cityMapEntryGroups(entries) { const groups = []; entries.forEach((entry) => { const current = groups[groups.length - 1]; if (current?.category === entry.category) { current.count += 1; return; } groups.push({ category: entry.category, count: 1 }); }); return groups; } function specialBookStackKey(specialId, category) { return `${specialId}--${category}`; } function activeSpecialStackIndex(stackKey, count) { return clamp(state.specialStackIndexes.get(stackKey) || 0, 0, Math.max(count - 1, 0)); } function activeSpecialBookEntry(group) { return group.entries[activeSpecialStackIndex(specialBookStackKey(activeSpecial()?.id || "", group.category), group.entries.length)] || group.entries[0]; } function visibleSpecialBookStackEntries(special, group) { const stackKey = specialBookStackKey(special.id, group.category); const activeIndex = activeSpecialStackIndex(stackKey, group.entries.length); const activeEntry = group.entries[activeIndex] || group.entries[0]; const entries = [{ entry: activeEntry, layer: 0 }]; const peekCount = Math.min(group.entries.length - 1, 3); for (let layer = 1; layer <= peekCount; layer += 1) { const targetIndex = (activeIndex + layer) % group.entries.length; const entry = group.entries[targetIndex]; if (entry) entries.push({ entry, layer }); } return entries.filter((item) => item.entry?.book && !item.entry.book.isPlaceholder); } function specialBookConnectorTarget(point, layer) { return { x: point.x + layer * 20, y: point.y + layer * 12, }; } function appendSpecialBookConnectors(fragment, special, group, point, index, hoverKey, origin, projection, stageRect) { visibleSpecialBookStackEntries(special, group).forEach(({ entry, layer }, visibleIndex) => { const anchor = specialBookAnchor(special, entry.book, origin, index + visibleIndex, stageRect, projection); const target = specialBookConnectorTarget(point, layer); fragment.append( createSvg("path", { d: specialConnectorPath(anchor, target, index + visibleIndex), class: `special-book-line${layer ? " special-book-line--peek" : ""}`, "data-book-key": hoverKey, "data-layer": layer, style: `--book-delay: ${Math.min((index + visibleIndex) * 34, 260)}ms`, }), ); fragment.append(createSpecialBookAnchorElement(anchor, hoverKey, index + visibleIndex, layer)); }); } function prepareSpecialOverlay(stageRect) { elements.specialOverlaySvg.setAttribute("width", `${Math.max(stageRect.width, 1)}`); elements.specialOverlaySvg.setAttribute("height", `${Math.max(stageRect.height, 1)}`); elements.specialOverlaySvg.setAttribute("viewBox", `0 0 ${Math.max(stageRect.width, 1)} ${Math.max(stageRect.height, 1)}`); } function resolveSpecialBookPoints(points, origin, stageRect, special, mode = "book") { const resolved = points.map((point) => ({ ...point })); const bounds = specialBookLayoutBounds(stageRect, special); const coreAvoidanceRect = mode === "book" ? null : specialBookCoreAvoidanceRect(stageRect, special, mode); const compactCityBook = special?.cityMap && mode === "book" && (stageRect.width < 1500 || stageRect.height < 1120); const box = { width: mode === "stack" ? (stageRect.width < 760 ? 174 : 210) : (stageRect.width < 760 ? 124 : (compactCityBook ? 164 : 210)), height: mode === "stack" ? (stageRect.width < 760 ? 252 : 420) : (stageRect.width < 760 ? 176 : (compactCityBook ? 236 : 300)), }; const layoutBox = specialBookStackCollisionBox(stageRect, mode, box); const minY = mode === "stack" ? Math.max(bounds.minY, stageRect.width < 760 ? 134 : 232) : Math.max(bounds.minY, special?.cityMap ? layoutBox.height / 2 + 24 : bounds.minY); const maxY = mode === "stack" ? Math.min(bounds.maxY, stageRect.height - (stageRect.width < 760 ? 168 : 230)) : Math.min(bounds.maxY, special?.cityMap ? stageRect.height - layoutBox.height / 2 - (stageRect.width < 760 ? 112 : 40) : bounds.maxY); const placementBounds = specialBookPlacementBounds(stageRect, special, mode, bounds, layoutBox); const rowPoints = specialBookTwoRowPoints(resolved.length, stageRect, special, placementBounds, minY, Math.max(maxY, minY), layoutBox, coreAvoidanceRect, mode); if (rowPoints) resolved.splice(0, resolved.length, ...rowPoints); for (let iteration = 0; iteration < 10; iteration += 1) { let changed = false; for (let i = 0; i < resolved.length; i += 1) { for (let j = i + 1; j < resolved.length; j += 1) { const a = resolved[i]; const b = resolved[j]; const dx = b.x - a.x || 0.1; const dy = b.y - a.y || 0.1; const overlapX = layoutBox.width - Math.abs(dx); const overlapY = layoutBox.height - Math.abs(dy); if (overlapX <= 0 || overlapY <= 0) continue; changed = true; if (overlapX < overlapY) { const push = (overlapX / 2 + 8) * Math.sign(dx); a.x -= push; b.x += push; } else { const push = (overlapY / 2 + 8) * Math.sign(dy); a.y -= push; b.y += push; } } } for (const point of resolved) { point.x = clamp(point.x, placementBounds.minX, placementBounds.maxX); point.y = clamp(point.y, minY, Math.max(maxY, minY)); if (Math.hypot(point.x - origin.x, point.y - origin.y) < 118) { const angle = Math.atan2(point.y - origin.y, point.x - origin.x); point.x = origin.x + Math.cos(angle) * 118; point.y = origin.y + Math.sin(angle) * 118; } if (avoidSpecialBookCore(point, coreAvoidanceRect, placementBounds, minY, Math.max(maxY, minY), layoutBox)) { changed = true; } } if (!changed) break; } return resolved; } function specialBookStackCollisionBox(stageRect, mode, box) { if (mode === "book") { return { width: box.width + (stageRect.width < 760 ? 32 : 14), height: box.height + (stageRect.width < 760 ? 48 : 24), }; } if (mode !== "stack") return box; return { width: stageRect.width < 760 ? Math.max(box.width, 224) : Math.max(box.width, 244), height: stageRect.width < 760 ? Math.max(box.height, 286) : Math.max(box.height, 350), }; } function specialBookPlacementBounds(stageRect, special, mode, bounds, box) { if (!special?.cityMap || (mode !== "stack" && mode !== "book")) return bounds; const horizontalBleed = mode === "book" ? (stageRect.width < 760 ? box.width * 0.08 : box.width * 0.22) : (stageRect.width < 760 ? box.width * 0.18 : box.width * 0.42); const edgePadding = stageRect.width < 760 ? 18 : 24; const minVisibleCenter = edgePadding + box.width / 2; const maxVisibleCenter = Math.max(minVisibleCenter, stageRect.width - edgePadding - box.width / 2); return { ...bounds, minX: Math.max(minVisibleCenter, bounds.minX - horizontalBleed), maxX: Math.min(maxVisibleCenter, bounds.maxX + horizontalBleed), }; } function specialBookTwoRowPoints(count, stageRect, special, bounds, minY, maxY, box, coreAvoidanceRect, mode) { if (!special?.cityMap || count <= 0) return null; const topCount = Math.ceil(count / 2); const bottomCount = count - topCount; const rowGap = stageRect.width < 760 ? 18 : 28; const musicSafeBottom = stageRect.width < 760 ? 150 : 118; const safeMaxY = Math.min(maxY, stageRect.height - musicSafeBottom - box.height / 2); if (mode === "book") { const spreadPoints = specialBookSpreadSlots(count, stageRect, bounds, minY, safeMaxY, box, special?.cityMapSpreadRows); if (spreadPoints.length >= count) return spreadPoints; } if (!coreAvoidanceRect) return null; const rowLift = stageRect.width < 760 ? 18 : 34; const rowJitter = stageRect.width < 760 ? 8 : 22; const topY = clamp(coreAvoidanceRect.top - rowGap - box.height / 2, minY + rowJitter, Math.max(minY + rowJitter, safeMaxY)); const bottomY = clamp( coreAvoidanceRect.bottom + rowGap + box.height / 2 - rowLift, minY, Math.max(minY, safeMaxY - rowJitter), ); const perimeterPoints = specialBookPerimeterSlots(count, stageRect, bounds, minY, safeMaxY, box, coreAvoidanceRect); if (perimeterPoints.length >= count) return perimeterPoints; const rows = [ { count: topCount, start: 0, y: topY, jitter: -rowJitter }, { count: bottomCount, start: topCount, y: bottomY, jitter: rowJitter }, ]; const points = []; rows.forEach((row) => { if (row.count <= 0) return; const usableMinX = bounds.minX + box.width * 0.38; const usableMaxX = bounds.maxX - box.width * 0.38; for (let index = 0; index < row.count; index += 1) { const ratio = row.count === 1 ? 0.5 : (index + 0.5) / row.count; const staggerY = row.count === 1 ? 0 : (index % 2 === 0 ? row.jitter : -row.jitter); points[row.start + index] = { x: usableMinX + (usableMaxX - usableMinX) * ratio, y: clamp(row.y + staggerY, minY, Math.max(minY, safeMaxY)), }; } }); return points; } function specialBookSpreadSlots(count, stageRect, bounds, minY, safeMaxY, box, rowsOverride) { const [topCount, bottomCount] = Array.isArray(rowsOverride) ? rowsOverride : [Math.ceil(count / 2), count - Math.ceil(count / 2)]; const rowJitter = stageRect.width < 760 ? 8 : 18; const rightControlSafe = stageRect.width < 760 ? 132 : 300; const topRowMaxX = Math.min(bounds.maxX, stageRect.width - rightControlSafe - box.width / 2); const centerSafeGap = { left: stageRect.width * (stageRect.width < 760 ? 0.3 : 0.38), right: stageRect.width * (stageRect.width < 760 ? 0.7 : 0.65), }; const bottomLeftCount = Math.floor(bottomCount / 2); const bottomRightCount = bottomCount - bottomLeftCount; const cityMapRowGroupGap = stageRect.width < 760 ? 0.22 : 0.64; const bottomY = clamp(safeMaxY + box.height * 0.16, minY, safeMaxY + box.height * 0.2); const bottomRowJitter = stageRect.width < 760 ? 24 : 80; const topStaggerPattern = [ rowJitter * 1.55, rowJitter * 3.9, -rowJitter * 0.9, rowJitter * 2.9, -rowJitter * 1.45, rowJitter * 4.55, ]; const bottomStaggerPattern = [ -bottomRowJitter, rowJitter * 2.35, rowJitter * 5.5, rowJitter * 5.1, bottomRowJitter * 0.98, -bottomRowJitter * 0.7, rowJitter * 2.55, ]; const rows = [ { count: topCount, start: 0, y: minY, jitter: -rowJitter }, { count: bottomCount, start: topCount, y: bottomY, jitter: rowJitter }, ]; const points = []; rows.forEach((row) => { if (row.count <= 0) return; const usableMinX = bounds.minX; const usableMaxX = row.start === 0 ? topRowMaxX : bounds.maxX; for (let index = 0; index < row.count; index += 1) { const rowGroups = row.start === 0 ? rowsOverride?.topGroups : rowsOverride?.bottomGroups; let ratio = cityMapGroupedRatio(index, row.count, rowGroups, cityMapRowGroupGap); let xMin = usableMinX; let xMax = usableMaxX; if (row.start !== 0) { const bottomIndex = index; const side = bottomIndex < bottomLeftCount ? "left" : "right"; const sideIndex = side === "left" ? bottomIndex : bottomIndex - bottomLeftCount; const sideCount = side === "left" ? bottomLeftCount : bottomRightCount; const sideGroups = cityMapSplitGroups(rowGroups, side === "left" ? 0 : bottomLeftCount, sideCount); const leftMaxX = Math.min(bounds.maxX, centerSafeGap.left - box.width * 0.55); const rightMinX = Math.max(bounds.minX, centerSafeGap.right + box.width * 0.55); ratio = cityMapGroupedRatio(sideIndex, sideCount, sideGroups, cityMapRowGroupGap); xMin = side === "left" ? usableMinX : rightMinX; xMax = side === "left" ? leftMaxX : usableMaxX; } const staggerPattern = row.start === 0 ? topStaggerPattern : bottomStaggerPattern; const staggerY = row.count === 1 ? 0 : staggerPattern[index % staggerPattern.length]; points[row.start + index] = { x: clamp(xMin + Math.max(xMax - xMin, 0) * ratio, bounds.minX, bounds.maxX), y: clamp(row.y + staggerY, minY, Math.max(safeMaxY, bottomY + bottomRowJitter * 1.35)), }; } }); return points.slice(0, count); } function cityMapGroupedRatio(index, count, groups, gap) { if (count <= 1) return 0.5; const usableGroups = Array.isArray(groups) && groups.length ? groups : [{ count }]; let groupStart = 0; let priorGaps = 0; for (const group of usableGroups) { const groupEnd = groupStart + group.count; if (index < groupEnd) break; priorGaps += 1; groupStart = groupEnd; } const totalUnits = count + Math.max(usableGroups.length - 1, 0) * gap; return (index + 0.5 + priorGaps * gap) / totalUnits; } function cityMapSplitGroups(groups, start, count) { if (!Array.isArray(groups) || count <= 0) return []; const result = []; let cursor = 0; const end = start + count; groups.forEach((group) => { const groupStart = cursor; const groupEnd = cursor + group.count; const overlapStart = Math.max(start, groupStart); const overlapEnd = Math.min(end, groupEnd); if (overlapEnd > overlapStart) { result.push({ category: group.category, count: overlapEnd - overlapStart }); } cursor = groupEnd; }); return result; } function specialBookPerimeterSlots(count, stageRect, bounds, minY, safeMaxY, box, coreAvoidanceRect) { const halfWidth = box.width / 2; const halfHeight = box.height / 2; const gap = stageRect.width < 760 ? 22 : 44; const looseGap = stageRect.width < 760 ? 26 : 58; const sideInset = stageRect.width < 760 ? 8 : 12; const coreHeight = coreAvoidanceRect.bottom - coreAvoidanceRect.top; const sideTop = clamp(coreAvoidanceRect.top + coreHeight * 0.2, minY + halfHeight * 0.2, safeMaxY - halfHeight * 0.16); const sideBottom = clamp(coreAvoidanceRect.bottom - coreHeight * 0.16, minY + halfHeight * 0.16, safeMaxY - halfHeight * 0.2); const sideMid = clamp(coreAvoidanceRect.centerY, minY + halfHeight * 0.2, safeMaxY - halfHeight * 0.2); const leftInnerX = clamp(coreAvoidanceRect.left - looseGap - halfWidth, bounds.minX + sideInset, bounds.maxX - sideInset); const rightInnerX = clamp(coreAvoidanceRect.right + looseGap + halfWidth, bounds.minX + sideInset, bounds.maxX - sideInset); const outerLeftX = clamp(bounds.minX + sideInset, bounds.minX + sideInset, bounds.maxX - sideInset); const outerRightX = clamp(bounds.maxX - sideInset, bounds.minX + sideInset, bounds.maxX - sideInset); const topY = clamp(coreAvoidanceRect.top - gap - halfHeight, minY, safeMaxY); const bottomY = clamp(coreAvoidanceRect.bottom + gap + halfHeight, minY, safeMaxY); const topSlots = [ { x: outerLeftX, y: topY }, { x: outerRightX, y: topY }, { x: leftInnerX, y: topY }, { x: rightInnerX, y: topY }, ]; const sideSlots = [ { x: outerLeftX, y: topY }, { x: outerRightX, y: topY }, { x: leftInnerX, y: bottomY }, { x: rightInnerX, y: bottomY }, { x: leftInnerX, y: topY }, { x: rightInnerX, y: topY }, { x: outerLeftX, y: bottomY }, { x: outerRightX, y: bottomY }, { x: leftInnerX, y: sideMid }, { x: rightInnerX, y: sideMid }, { x: outerLeftX, y: sideTop }, { x: outerRightX, y: sideTop }, { x: outerLeftX, y: sideBottom }, { x: outerRightX, y: sideBottom }, ]; const bottomSlots = [ { x: outerLeftX, y: bottomY }, { x: outerRightX, y: bottomY }, { x: leftInnerX, y: bottomY }, { x: rightInnerX, y: bottomY }, ]; const occupied = []; return [...sideSlots, ...topSlots, ...bottomSlots] .map((candidate) => ({ x: clamp(candidate.x, bounds.minX, bounds.maxX), y: clamp(candidate.y, minY, safeMaxY), })) .filter((candidate) => !pointBoxOverlapsRect(candidate, box, coreAvoidanceRect)) .filter((candidate) => { if (pointBoxOverlapsAny(candidate, box, occupied)) return false; occupied.push(pointBox(candidate, box)); return true; }) .slice(0, count); } function specialBookCoreAvoidanceRect(stageRect, special, mode) { if (!special?.cityMap || (mode !== "stack" && mode !== "book")) return null; const layout = special?.bookLayout?.coreAvoidance || {}; if (layout === false) return null; const mobile = stageRect.width < 760; const defaults = mobile ? { widthRatio: 0.5, heightRatio: 0.38, minWidth: 280, maxWidth: 390, minHeight: 210, maxHeight: 290, xRatio: 0.5, yRatio: 0.53 } : { widthRatio: 0.48, heightRatio: 0.42, minWidth: 620, maxWidth: 1040, minHeight: 310, maxHeight: 470, xRatio: 0.5, yRatio: 0.52 }; const widthRatio = layout.widthRatio ?? defaults.widthRatio; const heightRatio = layout.heightRatio ?? defaults.heightRatio; const width = layout.width ?? clamp(stageRect.width * widthRatio, layout.minWidth ?? defaults.minWidth, layout.maxWidth ?? defaults.maxWidth); const height = layout.height ?? clamp(stageRect.height * heightRatio, layout.minHeight ?? defaults.minHeight, layout.maxHeight ?? defaults.maxHeight); const centerX = layout.x ?? stageRect.width * (layout.xRatio ?? defaults.xRatio); const centerY = layout.y ?? stageRect.height * (layout.yRatio ?? defaults.yRatio); return { left: centerX - width / 2, right: centerX + width / 2, top: centerY - height / 2, bottom: centerY + height / 2, centerX, centerY, }; } function avoidSpecialBookCore(point, coreAvoidanceRect, bounds, minY, maxY, box) { if (!coreAvoidanceRect || !pointBoxOverlapsRect(point, box, coreAvoidanceRect)) return false; const margin = 36; const halfWidth = box.width / 2; const halfHeight = box.height / 2; const candidates = [ { x: coreAvoidanceRect.left - margin - halfWidth, y: clamp(point.y, minY, maxY), side: "left" }, { x: coreAvoidanceRect.right + margin + halfWidth, y: clamp(point.y, minY, maxY), side: "right" }, { x: clamp(point.x, bounds.minX, bounds.maxX), y: coreAvoidanceRect.top - margin - halfHeight, side: "top" }, { x: clamp(point.x, bounds.minX, bounds.maxX), y: coreAvoidanceRect.bottom + margin + halfHeight, side: "bottom" }, ] .map((candidate) => ({ ...candidate, x: clamp(candidate.x, bounds.minX, bounds.maxX), y: clamp(candidate.y, minY, maxY), })) .filter((candidate) => !pointBoxOverlapsRect(candidate, box, coreAvoidanceRect)); if (!candidates.length) return false; const preferredSide = Math.abs(point.x - coreAvoidanceRect.centerX) >= Math.abs(point.y - coreAvoidanceRect.centerY) ? (point.x < coreAvoidanceRect.centerX ? "left" : "right") : (point.y < coreAvoidanceRect.centerY ? "top" : "bottom"); candidates.sort((a, b) => { const aBias = a.side === preferredSide ? 0 : 160; const bBias = b.side === preferredSide ? 0 : 160; return Math.hypot(a.x - point.x, a.y - point.y) + aBias - (Math.hypot(b.x - point.x, b.y - point.y) + bBias); }); const moved = Math.abs(point.x - candidates[0].x) > 0.1 || Math.abs(point.y - candidates[0].y) > 0.1; point.x = candidates[0].x; point.y = candidates[0].y; return moved; } function pointBoxOverlapsRect(point, box, rect) { const candidate = pointBox(point, box); return ( candidate.right > rect.left && candidate.left < rect.right && candidate.bottom > rect.top && candidate.top < rect.bottom ); } function pointBoxOverlapsAny(point, box, rects) { return rects.some((rect) => pointBoxOverlapsRect(point, box, rect)); } function pointBox(point, box) { const halfWidth = box.width / 2; const halfHeight = box.height / 2; return { left: point.x - halfWidth, right: point.x + halfWidth, top: point.y - halfHeight, bottom: point.y + halfHeight, }; } function specialBookLayoutBounds(stageRect, special) { const layout = special?.bookLayout || {}; const mobile = stageRect.width < 760; const cityMap = Boolean(special?.cityMap); const sideSafe = mobile ? CITY_SPECIAL_BOOK_SAFE_ZONE.mobileSide : CITY_SPECIAL_BOOK_SAFE_ZONE.side; const cityTopSafe = mobile ? CITY_SPECIAL_BOOK_SAFE_ZONE.mobileTop : CITY_SPECIAL_BOOK_SAFE_ZONE.top; const cityBottomSafe = mobile ? CITY_SPECIAL_BOOK_SAFE_ZONE.mobileBottom : CITY_SPECIAL_BOOK_SAFE_ZONE.bottom; const minX = layout.minX ?? sideSafe; const maxRight = layout.maxRight ?? sideSafe; const minY = layout.minY ?? (cityMap ? cityTopSafe : 112); const maxBottom = layout.maxBottom ?? (cityMap ? cityBottomSafe : 92); return { minX, maxX: Math.max(stageRect.width - maxRight, minX), minY, maxY: Math.max(stageRect.height - maxBottom, minY), }; } function specialBookPoint(origin, index, stageRect, special, mode = "book") { const offsets = mode === "stack" ? SPECIAL_CATEGORY_STACK_OFFSETS : SPECIAL_BOOK_OFFSETS; const [dx, dy] = offsets[index % offsets.length]; const scale = stageRect.width < 760 ? 0.54 : stageRect.width < 1080 ? 0.76 : 1; const bounds = specialBookLayoutBounds(stageRect, special); const minY = mode === "stack" ? Math.max(bounds.minY, stageRect.width < 760 ? 134 : 232) : bounds.minY; const maxY = mode === "stack" ? Math.min(bounds.maxY, stageRect.height - (stageRect.width < 760 ? 168 : 230)) : bounds.maxY; return { x: clamp(origin.x + dx * scale, bounds.minX, bounds.maxX), y: clamp(origin.y + dy * scale, minY, Math.max(maxY, minY)), }; } function specialBookAnchor(special, book, origin, index, stageRect, projection) { const bookAnchor = parseBookAnchorCoordinate(book); if (bookAnchor) { if (special?.cityMap) { const point = projectCityMapPoint(bookAnchor, stageRect, special); return { x: clamp(point.x, 24, Math.max(stageRect.width - 24, 24)), y: clamp(point.y, 24, Math.max(stageRect.height - 24, 24)), }; } const [x, y, visible] = projectPoint(bookAnchor, projection); if (visible) { const point = svgToStageFreePoint(x, y); return { x: clamp(point.x, 24, Math.max(stageRect.width - 24, 24)), y: clamp(point.y, 24, Math.max(stageRect.height - 24, 24)), }; } } const boundary = mapData.specialBoundaries.get(special.cityId); const anchors = boundary?.anchors || []; if (anchors.length) { const anchorCoordinate = anchors[index % anchors.length]; const [x, y, visible] = projectPoint(anchorCoordinate, projection); if (visible) { const point = svgToStageFreePoint(x, y); return { x: clamp(point.x, 24, Math.max(stageRect.width - 24, 24)), y: clamp(point.y, 24, Math.max(stageRect.height - 24, 24)), }; } } const angle = (index / Math.max(SPECIAL_BOOK_OFFSETS.length, 1)) * Math.PI * 2 - Math.PI / 2; const ring = stageRect.width < 760 ? 12 : 18; return { x: origin.x + Math.cos(angle) * ring, y: origin.y + Math.sin(angle) * ring, }; } function parseBookAnchorCoordinate(book) { const coordinate = book?.["特辑锚点坐标"] || book?.specialAnchorCoordinate; if (Array.isArray(coordinate) && coordinate.length >= 2) { const lon = Number(coordinate[0]); const lat = Number(coordinate[1]); return Number.isFinite(lon) && Number.isFinite(lat) ? [lon, lat] : null; } if (typeof coordinate === "string") { const [lon, lat] = coordinate.split(/[,,\s]+/).map(Number); return Number.isFinite(lon) && Number.isFinite(lat) ? [lon, lat] : null; } return null; } function projectCityMapPoint(coordinate, stageRect, special) { const tileProvider = resolveCityTileProvider(special, currentTileRuntimeConfig()); const tileZoom = special.tileZoom || 12; const tileSize = tileProvider.tileSize || 256; const cityMapScale = Number(special.cityMapScale) > 0 ? Number(special.cityMapScale) : 1; const centerPixel = mercatorPixel(state.center, tileZoom, tileSize); const anchorPixel = mercatorPixel(coordinate, tileZoom, tileSize); return { x: stageRect.width / 2 + (anchorPixel.x - centerPixel.x) * cityMapScale, y: stageRect.height / 2 + (anchorPixel.y - centerPixel.y) * cityMapScale, }; } function cityMapConnectorPathOptions(point, stageRect) { return { verticalCurveDirection: point.y < stageRect.height / 2 ? 1 : -1, }; } function specialConnectorPath(origin, point, index, options = {}) { const dx = point.x - origin.x; const dy = point.y - origin.y; const distance = Math.hypot(dx, dy) || 1; if (options.verticalCurveDirection) { const curve = Math.min(132, Math.max(56, distance * 0.22)) * options.verticalCurveDirection; const c1 = { x: origin.x + dx * 0.3, y: origin.y + dy * 0.26 + curve, }; const c2 = { x: origin.x + dx * 0.68, y: origin.y + dy * 0.72 + curve, }; return `M ${origin.x.toFixed(2)} ${origin.y.toFixed(2)} C ${c1.x.toFixed(2)} ${c1.y.toFixed(2)} ${c2.x.toFixed(2)} ${c2.y.toFixed(2)} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`; } const normalX = -dy / distance; const normalY = dx / distance; const curve = Math.min(112, Math.max(42, distance * 0.2)) * (index % 2 === 0 ? 1 : -1); const c1 = { x: origin.x + dx * 0.24 + normalX * curve, y: origin.y + dy * 0.24 + normalY * curve, }; const c2 = { x: origin.x + dx * 0.72 + normalX * curve * 0.56, y: origin.y + dy * 0.72 + normalY * curve * 0.56, }; return `M ${origin.x.toFixed(2)} ${origin.y.toFixed(2)} C ${c1.x.toFixed(2)} ${c1.y.toFixed(2)} ${c2.x.toFixed(2)} ${c2.y.toFixed(2)} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`; } function createSpecialBookAnchorElement(anchor, hoverKey, index, layer = 0) { const group = createSvg("g", { class: `special-book-anchor${layer ? " special-book-anchor--peek" : ""}`, "data-book-key": hoverKey, "data-layer": layer, transform: `translate(${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)})`, style: `--book-delay: ${Math.min(index * 34, 260)}ms`, }); group.append( createSvg("circle", { class: "special-book-anchor__halo", r: 5 }), createSvg("circle", { class: "special-book-anchor__dot", r: 2.5 }), ); return group; } function createSpecialBookStackElement(special, group, point, index, hoverKey) { const stackKey = specialBookStackKey(special.id, group.category); const activeIndex = activeSpecialStackIndex(stackKey, group.entries.length); const activeEntry = group.entries[activeIndex] || group.entries[0]; const book = activeEntry.book; const isMulti = group.entries.length > 1; const root = document.createElement("div"); root.className = `special-book special-book-stack${isMulti ? " is-multi" : ""}${state.activeSpecialStackKey === stackKey ? " is-stack-active" : ""}${book.isPlaceholder ? " special-book--empty" : ""}`; root.dataset.bookKey = hoverKey; root.dataset.stackKey = stackKey; root.style.setProperty("--book-x", `${point.x.toFixed(2)}px`); root.style.setProperty("--book-y", `${point.y.toFixed(2)}px`); root.style.setProperty("--book-delay", `${Math.min(index * 46, 260)}ms`); root.style.setProperty("--stack-count", String(group.entries.length)); const peekCount = Math.min(group.entries.length - 1, 3); for (let peekIndex = peekCount; peekIndex >= 1; peekIndex -= 1) { const targetIndex = (activeIndex + peekIndex) % group.entries.length; const peekEntry = group.entries[targetIndex]; const peek = document.createElement("button"); peek.type = "button"; peek.className = `special-book__peek special-book__peek--${peekIndex}`; peek.setAttribute("aria-label", `切到 ${peekEntry?.book?.titleZh || group.category}`); peek.append(createSpecialBookmarkContent(special, peekEntry.book, group.category)); peek.addEventListener("click", (event) => { event.stopPropagation(); state.activeSpecialStackKey = stackKey; state.specialStackIndexes.set(stackKey, targetIndex); trackSpecialStackSwitched(special, group, stackKey, targetIndex, "special_stack_peek"); playUiSound("pageFlick"); updateSpecialBookStack(stackKey); }); root.append(peek); } const button = document.createElement("button"); button.className = "special-book__hit"; button.type = "button"; button.setAttribute("aria-label", isMulti ? `${group.category},${activeIndex + 1}/${group.entries.length},${book.titleZh || special.specialName}` : `查看 ${book.titleZh || special.specialName}`); button.append(createSpecialBookmarkContent(special, book, group.category)); root.append(button); root.addEventListener("pointerenter", () => setSpecialBookHover(hoverKey, true)); root.addEventListener("pointerleave", () => setSpecialBookHover(hoverKey, false)); button.addEventListener("focus", () => setSpecialBookHover(hoverKey, true)); button.addEventListener("blur", () => setSpecialBookHover(hoverKey, false)); button.addEventListener("click", () => { if (book.isPlaceholder) return; if (isMulti && state.activeSpecialStackKey !== stackKey) { state.activeSpecialStackKey = stackKey; trackSpecialStackSwitched(special, group, stackKey, activeIndex, "special_stack_focus"); playUiSound("softTap"); updateSpecialBookStack(stackKey); return; } openSpecialBook(special, book, point, activeEntry.sourceIndex); }); return root; } function createSpecialBookmarkContent(special, book, category = specialBookCategory(book, special)) { const bookmark = document.createElement("span"); bookmark.className = "special-book__bookmark"; const cover = document.createElement("span"); cover.className = "special-book__cover"; if (book.coverUrl) { const image = document.createElement("img"); cover.classList.add("is-cover-loading"); image.decoding = "async"; image.loading = "eager"; image.addEventListener("load", () => { cover.classList.remove("is-cover-loading"); cover.classList.add("is-cover-ready"); scheduleTicketCutPaths(bookmark); }, { once: true }); image.addEventListener("error", () => { cover.classList.remove("is-cover-loading"); cover.classList.add("is-cover-failed"); image.hidden = true; const fallback = document.createElement("span"); fallback.className = "special-book__fallback"; fallback.textContent = fallbackMark(book, special); cover.append(fallback); scheduleTicketCutPaths(bookmark); }, { once: true }); image.src = book.coverUrl; image.alt = ""; cover.append(image); } else { const fallback = document.createElement("span"); fallback.className = "special-book__fallback"; fallback.textContent = fallbackMark(book, special); cover.append(fallback); } const body = document.createElement("span"); body.className = "special-book__body"; const context = document.createElement("span"); context.className = "special-book__context"; const contextZh = document.createElement("span"); contextZh.className = "special-book__context-zh"; contextZh.textContent = category; const contextDivider = document.createElement("span"); contextDivider.className = "special-book__context-divider"; contextDivider.textContent = "|"; const contextEn = document.createElement("span"); contextEn.className = "special-book__context-en"; contextEn.textContent = specialBookCategoryEn(category); context.append(contextZh, contextDivider, contextEn); const title = document.createElement("span"); title.className = "special-book__title"; title.textContent = book.titleZh || "Curating"; const rule = document.createElement("span"); rule.className = "special-book__rule"; rule.setAttribute("aria-hidden", "true"); const meta = document.createElement("span"); meta.className = "special-book__meta"; meta.textContent = bookMeta(book); meta.hidden = !meta.textContent; const quote = document.createElement("span"); quote.className = "special-book__quote"; quote.textContent = shortBookText(book); quote.hidden = !quote.textContent; body.append(context, title, rule, meta, quote); bookmark.append(cover, body); return bookmark; } function createSpecialBookElement(special, book, point, index, hoverKey, mode = "special", category) { hoverKey = hoverKey ?? specialBookKey(special.id, index); category = category ?? specialBookCategory(book, special); const button = document.createElement("button"); button.className = `special-book special-book--${mode}${book.isPlaceholder ? " special-book--empty" : ""}`; button.type = "button"; button.dataset.bookKey = hoverKey; button.dataset.bookId = book.id || ""; button.style.setProperty("--book-x", `${point.x.toFixed(2)}px`); button.style.setProperty("--book-y", `${point.y.toFixed(2)}px`); button.style.setProperty("--book-delay", `${Math.min(index * 34, 260)}ms`); button.setAttribute("aria-label", book.titleZh ? `查看 ${book.titleZh}` : special.specialName); const hit = document.createElement("span"); hit.className = "special-book__hit"; hit.append(createSpecialBookmarkContent(special, book, category)); button.append(hit); button.addEventListener("pointerdown", (event) => beginSpecialBookDrag(event, special, book, point, hoverKey)); button.addEventListener("pointerenter", () => setSpecialBookHover(hoverKey, true)); button.addEventListener("pointerleave", () => setSpecialBookHover(hoverKey, false)); button.addEventListener("focus", () => setSpecialBookHover(hoverKey, true)); button.addEventListener("blur", () => setSpecialBookHover(hoverKey, false)); button.addEventListener("click", (event) => { if (shouldSuppressSpecialBookClick(hoverKey)) { event.preventDefault(); event.stopPropagation(); return; } if (book.isPlaceholder) return; openSpecialBook(special, book, point, index); }); return button; } function beginSpecialBookDrag(event, special, book, point, hoverKey) { if (!special?.cityMap || book.isPlaceholder || event.button > 0) return; event.stopPropagation(); const element = event.currentTarget; const stageRect = elements.mapStage.getBoundingClientRect(); const rect = element.getBoundingClientRect(); state.specialBookDrag = { pointerId: event.pointerId, hoverKey, draftKey: specialBookDraftKey(special.id, book.id), element, point, startX: event.clientX, startY: event.clientY, startPoint: { ...point }, width: rect.width, height: rect.height, stageRect, bounds: cityMapBookDragBounds(special, stageRect, rect), moved: false, }; element.classList.add("is-dragging"); element.setPointerCapture?.(event.pointerId); window.addEventListener("pointermove", moveSpecialBookDrag); window.addEventListener("pointerup", endSpecialBookDrag, { once: true }); window.addEventListener("pointercancel", endSpecialBookDrag, { once: true }); } function moveSpecialBookDrag(event) { const drag = state.specialBookDrag; if (!drag || drag.pointerId !== event.pointerId) return; event.preventDefault(); const dx = event.clientX - drag.startX; const dy = event.clientY - drag.startY; if (!drag.moved && Math.hypot(dx, dy) < 4) return; drag.moved = true; const rawPoint = { x: clamp(drag.startPoint.x + dx, drag.bounds.minX, drag.bounds.maxX), y: clamp(drag.startPoint.y + dy, drag.bounds.minY, drag.bounds.maxY), }; const snapped = applySnapToGuides( { id: drag.hoverKey, x: rawPoint.x - drag.width / 2, y: rawPoint.y - drag.height / 2, width: drag.width, height: drag.height, }, currentSpecialBookDragItems(drag), { threshold: 10 }, ); const nextPoint = { x: clamp(snapped.x + drag.width / 2, drag.bounds.minX, drag.bounds.maxX), y: clamp(snapped.y + drag.height / 2, drag.bounds.minY, drag.bounds.maxY), }; commitSpecialBookDragPoint(drag, avoidCityMapBookForbiddenRects(nextPoint, drag)); } function cityMapBookDragBounds(special, stageRect, rect) { const halfWidth = rect.width / 2; const halfHeight = rect.height / 2; const safeHalfWidth = special?.cityMap ? halfWidth * CITY_MAP_BOOK_HOVER_SAFE_SCALE + CITY_MAP_BOOK_FORBIDDEN_GAP : halfWidth; const safeHalfHeight = special?.cityMap ? halfHeight * CITY_MAP_BOOK_HOVER_SAFE_SCALE + CITY_MAP_BOOK_FORBIDDEN_GAP : halfHeight; const bottomForbiddenTop = cityMapBookBottomForbiddenTop(stageRect); const minY = special?.cityMap ? cityMapBookTopForbiddenBottom(special) + safeHalfHeight : halfHeight; return { minX: safeHalfWidth, maxX: Math.max(safeHalfWidth, stageRect.width - safeHalfWidth), minY, maxY: Math.max(minY, bottomForbiddenTop - safeHalfHeight), safeWidth: safeHalfWidth * 2, safeHeight: safeHalfHeight * 2, forbiddenRects: special?.cityMap ? cityMapBookForbiddenRects(stageRect) : [], }; } function cityMapBookBottomForbiddenTop(stageRect) { const musicTop = elementStageTopEdge(elements.musicSignature, stageRect); if (musicTop != null) return musicTop; return stageRect.height - CITY_MAP_BOOK_BOTTOM_FORBIDDEN_TOP_OFFSET; } function cityMapBookForbiddenRects(stageRect) { return [elements.specialBackButton] .map((element) => elementStageRect(element, stageRect, 8)) .filter(Boolean); } function elementStageRect(element, stageRect, padding = 0) { if (!element || element.hidden) return null; const rect = element.getBoundingClientRect(); if (!rect.width || !rect.height || rect.right <= stageRect.left || rect.left >= stageRect.right) return null; const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null; return { left: rect.left - stageRect.left - padding, right: rect.right - stageRect.left + padding, top: rect.top - stageRect.top - padding, bottom: rect.bottom - stageRect.top + padding, }; } function avoidCityMapBookForbiddenRects(point, drag) { let resolved = { ...point }; drag.bounds.forbiddenRects.forEach((forbiddenRect) => { const safeSize = { width: drag.bounds.safeWidth ?? drag.width, height: drag.bounds.safeHeight ?? drag.height, }; const box = pointBox(resolved, safeSize); if ( box.right <= forbiddenRect.left || box.left >= forbiddenRect.right || box.bottom <= forbiddenRect.top || box.top >= forbiddenRect.bottom ) { return; } const candidates = [ { x: forbiddenRect.left - safeSize.width / 2, y: resolved.y }, { x: forbiddenRect.right + safeSize.width / 2, y: resolved.y }, { x: resolved.x, y: forbiddenRect.top - safeSize.height / 2 }, { x: resolved.x, y: forbiddenRect.bottom + safeSize.height / 2 }, ] .map((candidate) => ({ x: clamp(candidate.x, drag.bounds.minX, drag.bounds.maxX), y: clamp(candidate.y, drag.bounds.minY, drag.bounds.maxY), })) .filter((candidate) => { const candidateBox = pointBox(candidate, safeSize); return ( candidateBox.right <= forbiddenRect.left || candidateBox.left >= forbiddenRect.right || candidateBox.bottom <= forbiddenRect.top || candidateBox.top >= forbiddenRect.bottom ); }); if (!candidates.length) return; candidates.sort((a, b) => ( Math.hypot(a.x - resolved.x, a.y - resolved.y) - Math.hypot(b.x - resolved.x, b.y - resolved.y) )); resolved = candidates[0]; }); return resolved; } function commitSpecialBookDragPoint(drag, nextPoint) { drag.point.x = nextPoint.x; drag.point.y = nextPoint.y; drag.element.style.setProperty("--book-x", `${nextPoint.x.toFixed(2)}px`); drag.element.style.setProperty("--book-y", `${nextPoint.y.toFixed(2)}px`); state.specialBookDraftPoints.set(drag.draftKey, { ...nextPoint }); updateDraggedSpecialBookConnector(drag.hoverKey, nextPoint); } function endSpecialBookDrag(event) { const drag = state.specialBookDrag; if (!drag || drag.pointerId !== event.pointerId) return; window.removeEventListener("pointermove", moveSpecialBookDrag); drag.element.releasePointerCapture?.(event.pointerId); drag.element.classList.remove("is-dragging"); if (drag.moved) { state.specialBookSuppressedClickKey = drag.hoverKey; window.setTimeout(() => { if (state.specialBookSuppressedClickKey === drag.hoverKey) { state.specialBookSuppressedClickKey = null; } }, 800); } state.specialBookDrag = null; } function shouldSuppressSpecialBookClick(hoverKey) { if (state.specialBookSuppressedClickKey !== hoverKey) return false; state.specialBookSuppressedClickKey = null; return true; } function currentSpecialBookDragItems(drag) { return Array.from(elements.specialBookLayer.querySelectorAll(".special-book[data-book-key]")).map((element) => { if (element === drag.element) { return { id: drag.hoverKey, x: drag.point.x - drag.width / 2, y: drag.point.y - drag.height / 2, width: drag.width, height: drag.height, }; } const rect = element.getBoundingClientRect(); return { id: element.dataset.bookKey, x: rect.left - drag.stageRect.left, y: rect.top - drag.stageRect.top, width: rect.width, height: rect.height, }; }); } function updateDraggedSpecialBookConnector(hoverKey, point) { const data = state.specialBookRenderData.get(hoverKey); if (!data) return; data.point = point; elements.specialOverlaySvg .querySelectorAll(`.special-book-line[data-book-key="${hoverKey}"]`) .forEach((line) => { line.setAttribute("d", specialConnectorPath(data.anchor, point, data.index, cityMapConnectorPathOptions(point, data.stageRect))); }); } function specialBookCategory(book, special) { return book?.["特辑分类"] || book?.category || special?.specialName || "城市书单"; } function specialBookCategoryEn(category) { return SPECIAL_CATEGORY_EN.get(category) || category || "City Books"; } function specialBookIssueZh(special, category) { return [special?.city?.cityZh || special?.city?.cityEn, category].filter(Boolean).join(" · "); } function specialBookIssueEn(special, category) { return [special?.city?.cityEn || special?.city?.cityZh, specialBookCategoryEn(category)].filter(Boolean).join(" · "); } function createSpecialEntryMark(specialId) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("special-capsule__mark"); svg.setAttribute("viewBox", "0 0 18 18"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); const maskId = `special-entry-mark-cutout-${specialId}`; const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); const mask = document.createElementNS("http://www.w3.org/2000/svg", "mask"); mask.setAttribute("id", maskId); mask.setAttribute("maskUnits", "userSpaceOnUse"); const maskBase = document.createElementNS("http://www.w3.org/2000/svg", "rect"); maskBase.setAttribute("width", "18"); maskBase.setAttribute("height", "18"); maskBase.setAttribute("fill", "#fff"); const maskPin = document.createElementNS("http://www.w3.org/2000/svg", "path"); maskPin.setAttribute("d", "M9 3.3c-1.55 0-2.75 1.16-2.75 2.7 0 1.95 2.75 4.7 2.75 4.7s2.75-2.75 2.75-4.7c0-1.54-1.2-2.7-2.75-2.7Z"); maskPin.setAttribute("fill", "#000"); maskPin.setAttribute("stroke", "#000"); maskPin.setAttribute("stroke-width", "2.2"); maskPin.setAttribute("stroke-linejoin", "round"); mask.append(maskBase, maskPin); defs.append(mask); const bookPath = "M3.4 7.1c1.7-.8 3.5-.7 5.6.8 2.1-1.5 3.9-1.6 5.6-.8v5.2c-1.8-.7-3.6-.5-5.6.8-2-1.3-3.8-1.5-5.6-.8Z"; const bookGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); bookGroup.classList.add("special-capsule__mark-book-group"); bookGroup.setAttribute("mask", `url(#${maskId})`); const bookDepth = document.createElementNS("http://www.w3.org/2000/svg", "path"); bookDepth.classList.add("special-capsule__mark-book-depth"); bookDepth.setAttribute("d", "M3.45 14.25c1.9-.58 3.72-.22 5.55.88 1.83-1.1 3.65-1.46 5.55-.88"); const book = document.createElementNS("http://www.w3.org/2000/svg", "path"); book.classList.add("special-capsule__mark-book"); book.setAttribute("d", bookPath); const spine = document.createElementNS("http://www.w3.org/2000/svg", "path"); spine.classList.add("special-capsule__mark-spine"); spine.setAttribute("d", "M9 7.9v5.2"); bookGroup.append(bookDepth, book, spine); const pin = document.createElementNS("http://www.w3.org/2000/svg", "path"); pin.classList.add("special-capsule__mark-pin"); pin.setAttribute("d", "M9 3.3c-1.55 0-2.75 1.16-2.75 2.7 0 1.95 2.75 4.7 2.75 4.7s2.75-2.75 2.75-4.7c0-1.54-1.2-2.7-2.75-2.7Z"); svg.append(defs, bookGroup, pin); return svg; } function specialBookKey(scope, index) { return `${scope}--book-${index}`; } function specialBookDraftKey(scope, bookId) { return `${scope}--${bookId || "book"}`; } function setSpecialBookHover(hoverKey, hovered) { elements.specialBookLayer .querySelectorAll(`[data-book-key="${hoverKey}"]`) .forEach((element) => element.classList.toggle("is-hovered", hovered)); elements.specialOverlaySvg .querySelectorAll(`[data-book-key="${hoverKey}"]`) .forEach((element) => element.classList.toggle("is-hovered", hovered)); } function fallbackMark(book, special) { if (book.isPlaceholder) return "···"; return String(book.titleZh || special.specialName || "BOOK").slice(0, 2).toUpperCase(); } function curatingPlaceholderBook(special) { return { bookId: `${special.id}--curating`, titleZh: "Curating", category: special.city.cityZh || special.city.cityEn || special.specialName, isPlaceholder: true, }; } async function fetchJson(url) { const separator = url.includes("?") ? "&" : "?"; const response = await fetch(`${url}${separator}v=${DATA_VERSION}`); if (!response.ok) throw new Error(`Failed to load ${url}: ${response.status}`); return response.json(); } async function loadSpecialBoundaries(specials) { const entries = await Promise.all(specials.map(async (special) => { try { const [boundary, outline, visual] = await Promise.all([ fetchJson(`public/maps/city-specials/${special.cityId}.geojson`), fetchJson(`public/maps/city-specials/${special.cityId}-outline.geojson`).catch(() => null), fetchJson(`public/maps/city-specials/${special.cityId}-visual.geojson`).catch(() => null), ]); return [special.cityId, prepareSpecialBoundary(special, boundary, outline, visual)]; } catch { return [special.cityId, { features: [], anchors: [] }]; } })); return new Map(entries); } function ensureSpecialDataLoaded(specialOrSpecials = mapData.specials) { const specials = Array.isArray(specialOrSpecials) ? specialOrSpecials : [specialOrSpecials]; const validSpecials = specials.filter(Boolean); if (validSpecials.length === mapData.specials.length) { if (!specialDataLoadPromise) { specialDataLoadPromise = Promise.all([ loadSpecialBoundaries(mapData.specials), loadSpecialContexts(mapData.specials.filter((special) => special.cityMap || special.useLocalContext)), ]).then(([boundaries, contexts]) => { mapData.specialBoundaries = boundaries; mapData.specialContexts = contexts; return { boundaries, contexts }; }); } return specialDataLoadPromise; } return Promise.all(validSpecials.map(loadSingleSpecialData)); } function loadSingleSpecialData(special) { const key = special.cityId || special.id; if (!key) return Promise.resolve(null); if (!specialDataLoadPromises.has(key)) { specialDataLoadPromises.set(key, Promise.all([ loadSpecialBoundaries([special]), special.cityMap || special.useLocalContext ? loadSpecialContexts([special]) : Promise.resolve(new Map()), ]).then(([boundaries, contexts]) => { for (const [cityId, boundary] of boundaries) mapData.specialBoundaries.set(cityId, boundary); for (const [cityId, context] of contexts) mapData.specialContexts.set(cityId, context); return { boundary: mapData.specialBoundaries.get(key), context: mapData.specialContexts.get(key), }; })); } return specialDataLoadPromises.get(key); } async function loadSpecialContexts(specials) { const entries = await Promise.all(specials.map(async (special) => { try { const context = await fetchJson(`public/maps/city-specials/${special.cityId}-context.geojson`); return [special.cityId, { features: prepareContextFeatures(context), landMaskRings: prepareContextLandMaskRings(context), }]; } catch { return [special.cityId, { features: [], landMaskRings: [] }]; } })); return new Map(entries); } function prepareSpecialBoundary(special, boundary, outline = null, visual = null) { const displayBoundary = outline?.features?.length ? outline : boundary; const features = prepareWorldFeatures(boundary); const outlineFeatures = prepareWorldFeatures(displayBoundary); const visualFeatures = visual?.features?.length ? prepareWorldFeatures(visual) : []; const landMaskRings = visual?.features?.length ? prepareCityLandMaskRings(visual) : []; const displayFeature = displayBoundary.features?.[0]; const code = boundary.properties?.code || displayFeature?.properties?.code || ""; const anchors = (boundary.features || []) .map((feature) => feature.properties?.center) .filter((point) => { return Array.isArray(point) && Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1])); }) .map((point) => [Number(point[0]), Number(point[1])]); if (!anchors.length) { const center = displayBoundary.features?.[0]?.properties?.center || displayBoundary.properties?.center; if (Array.isArray(center) && Number.isFinite(Number(center[0])) && Number.isFinite(Number(center[1]))) { anchors.push([Number(center[0]), Number(center[1])]); } } return { features, outlineFeatures, visualFeatures, landMaskRings, anchors, code: String(code) }; } function prepareContextFeatures(collection) { return (collection.features || []) .filter((feature) => ["LineString", "MultiLineString", "Polygon", "MultiPolygon"].includes(feature?.geometry?.type)) .map((feature) => { const flatCoordinates = flattenCoordinates(feature.geometry.coordinates); return { feature, flatCoordinates, centroid: calculateCentroid(flatCoordinates), }; }); } function prepareContextLandMaskRings(collection) { return prepareCityLandMaskRings({ features: (collection.features || []).filter((feature) => { return feature.properties?.kind === "land" && ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type); }), }); } function featureIsoA3(feature) { return feature.properties?.ISO_A3 || feature.properties?.ADM0_A3 || feature.properties?.ADM0_A3_US || ""; } function countryLabelIso(feature) { const props = feature.properties || {}; const iso = props.ISO_A3 && props.ISO_A3 !== "-99" ? props.ISO_A3 : props.ADM0_A3; return iso || props.ADM0_A3_US || ""; } function countryLabelEntry(worldFeature, projection) { const feature = worldFeature.feature; const props = feature.properties || {}; const iso = countryLabelIso(feature); if (!shouldRenderCountryLabel(props, iso)) return null; const point = countryLabelPoint(worldFeature); const [x, y, visible] = projectPoint(point, projection); if (!visible) return null; if (!countryLabelWithinSafeGlobeArea({ x, y }, projection)) return null; return { x, y, name: COUNTRY_LABEL_OVERRIDES.get(iso) || props.NAME_EN || props.NAME || props.ADMIN, rank: clamp(Number(props.LABELRANK || 5), 1, 6), }; } function shouldRenderCountryLabel(props, iso) { if (!iso || COUNTRY_LABEL_EXCLUDED_ISO.has(iso)) return false; if (String(props.TYPE || "").toLowerCase().includes("dependency")) return false; if (props.SOVEREIGNT && props.ADMIN && props.SOVEREIGNT !== props.ADMIN) return false; const rank = Number(props.LABELRANK || 99); const maxRank = state.zoom >= COUNTRY_LABEL_POLICY.detailZoom ? COUNTRY_LABEL_POLICY.detailMaxRank : COUNTRY_LABEL_POLICY.overviewMaxRank; if (!Number.isFinite(rank) || rank > maxRank) return false; if (isMicroCountry(props) && state.zoom < COUNTRY_LABEL_POLICY.microMinZoom) return false; if (isTinyCountry(props) && state.zoom < COUNTRY_LABEL_POLICY.tinyMinZoom) return false; return true; } function countryLabelWithinSafeGlobeArea(point, projection) { const dx = point.x - projection.cx; const dy = point.y - projection.cy; return Math.hypot(dx, dy) <= projection.radius * COUNTRY_LABEL_EDGE_RATIO; } function isTinyCountry(props) { return Number(props.LABELRANK || 99) >= 6 || Number(props.TINY || -99) >= 5; } function isMicroCountry(props) { return Number(props.LABELRANK || 99) >= 6 && (Number(props.TINY || -99) >= 5 || Number(props.POP_RANK || 99) <= 7); } function countryLabelPoint(worldFeature) { const props = worldFeature.feature.properties || {}; const lon = Number(props.LABEL_X); const lat = Number(props.LABEL_Y); if (Number.isFinite(lon) && Number.isFinite(lat)) { const labelPoint = [lon, lat]; if (pointInFeature(labelPoint, worldFeature.feature)) return labelPoint; } if (pointInFeature(worldFeature.centroid, worldFeature.feature)) { return worldFeature.centroid; } const interiorPoint = largestPolygonInteriorPoint(worldFeature.feature); if (interiorPoint) return interiorPoint; if (Number.isFinite(lon) && Number.isFinite(lat)) return [lon, lat]; return worldFeature.centroid; } function pointInFeature(point, feature) { const geometry = feature?.geometry; if (!geometry || !Array.isArray(point)) return false; if (geometry.type === "Polygon") return pointInPolygon(point, geometry.coordinates); if (geometry.type === "MultiPolygon") { return geometry.coordinates.some((polygon) => pointInPolygon(point, polygon)); } return false; } function pointInPolygon(point, rings) { if (!Array.isArray(rings) || !rings.length) return false; if (!pointInRing(point, rings[0])) return false; return !rings.slice(1).some((ring) => pointInRing(point, ring)); } function pointInRing([x, y], ring) { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { const [xi, yi] = ring[i]; const [xj, yj] = ring[j]; const intersects = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi); if (intersects) inside = !inside; } return inside; } function largestPolygonInteriorPoint(feature) { const geometry = feature?.geometry; const polygons = geometry?.type === "Polygon" ? [geometry.coordinates] : geometry?.type === "MultiPolygon" ? geometry.coordinates : []; const rings = polygons .map((polygon) => polygon?.[0]) .filter(Boolean) .sort((a, b) => Math.abs(ringArea(b)) - Math.abs(ringArea(a))); for (const ring of rings) { const candidate = ringCentroid(ring); if (candidate && pointInFeature(candidate, feature)) return candidate; const fallback = ringBBoxCenter(ring); if (fallback && pointInFeature(fallback, feature)) return fallback; } return null; } function ringArea(ring) { let area = 0; for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { area += (ring[j][0] * ring[i][1]) - (ring[i][0] * ring[j][1]); } return area / 2; } function ringCentroid(ring) { const area = ringArea(ring); if (!Number.isFinite(area) || Math.abs(area) < 1e-8) return null; let x = 0; let y = 0; for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { const cross = (ring[j][0] * ring[i][1]) - (ring[i][0] * ring[j][1]); x += (ring[j][0] + ring[i][0]) * cross; y += (ring[j][1] + ring[i][1]) * cross; } return [x / (6 * area), y / (6 * area)]; } function ringBBoxCenter(ring) { const xs = ring.map(([x]) => x); const ys = ring.map(([, y]) => y); return [ (Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2, ]; } function isSpecialMapFeature(feature, special, boundary, useRegionalChina) { if (!special) return false; if (useRegionalChina) { const featureCode = feature.properties?.code; if (boundary?.code && String(featureCode) === boundary.code) return true; const featureName = feature.properties?.fullname || feature.properties?.name; return Boolean(featureName && featureName === special.city.cityZh); } return featureIsoA3(feature) === special.city?.isoA3; } function normalizeCities(cities = []) { return cities.filter((city) => { const [lon, lat] = city.coordinates || [city.lon, city.lat]; return city.mapStatus !== "hidden" && Number.isFinite(Number(lon)) && Number.isFinite(Number(lat)); }); } function normalizeBookRecords(books = []) { return books.map((book) => ({ ...book, bookId: book.bookId || book.id, coverUrl: bestCoverUrl(book), })); } function bookIsSelected(book) { return book?.selectionStatus === "selected" || book?.isSelected === true; } function buildSpecials() { const pointsByCity = new Map(mapData.cities.map((city) => [city.cityId || city.id, city])); const catalogByCity = new Map(mapData.cityCatalog.map((city) => [city.id, city])); const booksBySpecial = groupBooksBySpecial(mapData.books); const selectedBooksByCity = groupSelectedBooksByCity(mapData.cities); return SPECIAL_DEFINITIONS.map((definition) => { const point = pointsByCity.get(definition.cityId); const catalogCity = catalogByCity.get(definition.cityId); const city = { ...(catalogCity || {}), ...(point || {}), id: point?.id || definition.cityId, cityId: definition.cityId, }; const curatedBooks = booksBySpecial.get(definition.specialName) || []; const fallbackBooks = selectedBooksByCity.get(definition.cityId) || []; const books = curatedBooks.length ? curatedBooks : fallbackBooks; return { ...definition, city, books, }; }).filter((special) => cityCoordinates(special.city).every((value) => Number.isFinite(value))); } function groupBooksBySpecial(books) { const groups = new Map(); for (const book of books) { const specialName = book["特辑名称"]; if (!specialName || specialName === "否") continue; if (!groups.has(specialName)) groups.set(specialName, []); groups.get(specialName).push(specialBookPayload(book)); } for (const group of groups.values()) { group.sort((a, b) => Number(a["特辑排序"] || 999) - Number(b["特辑排序"] || 999)); } return groups; } function groupSelectedBooksByCity(points) { const groups = new Map(); for (const point of points) { const cityId = point.cityId || point.id; if (!cityId) continue; groups.set(cityId, pointBooks(point).map(specialBookPayload)); } return groups; } function specialBookPayload(book) { return { ...book, bookId: book.bookId || book.id, coverUrl: bestCoverUrl(book), }; } function bestCoverUrl(book) { return book?.localCoverUrl || book?.coverUrl || book?.remoteCoverUrl || ""; } function prepareWorldFeatures(world) { return (world.features || []).filter(isAreaFeature).map((feature) => { const flatCoordinates = flattenCoordinates(feature.geometry.coordinates); return { feature, pathKey: `geo-${globeFeaturePathId += 1}`, flatCoordinates, centroid: calculateCentroid(flatCoordinates), }; }); } function prepareCityLandMaskRings(collection) { const rings = []; for (const feature of collection.features || []) { const geometry = feature?.geometry; if (!geometry || !["Polygon", "MultiPolygon"].includes(geometry.type)) continue; const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates; for (const polygon of polygons) { for (const ring of polygon) { rings.push({ ring, bounds: ringGeoBounds(ring) }); } } } return rings; } function ringGeoBounds(ring) { let minLon = Infinity; let maxLon = -Infinity; let minLat = Infinity; let maxLat = -Infinity; for (const [lon, lat] of ring) { minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon); minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); } return { minLon, maxLon, minLat, maxLat }; } function isAreaFeature(feature) { return ["Polygon", "MultiPolygon"].includes(feature?.geometry?.type); } function renderCities(projection, options = {}) { const visibleCityIds = new Set(); const visibleEntries = []; const special = activeSpecial(); const cities = special ? [special.city] : mapData.cities; const stageProjection = projectionToStageGeometry(projection); for (const city of cities) { const [lon, lat] = cityCoordinates(city); const [x, y, visible] = projectPoint([lon, lat], projection); if (!visible) continue; visibleCityIds.add(city.id); const markerEntry = cityMarkerCache.get(city.id) || createCityMarker(city); const point = svgToStageFreePoint(x, y); updateCityMarkerBase(markerEntry, city, point); if (markerEntry.element.parentNode !== elements.cityLabelLayer) { elements.cityLabelLayer.append(markerEntry.element); } visibleEntries.push({ city, markerEntry, point }); } for (const cityId of cityMarkerCache.keys()) { const markerEntry = cityMarkerCache.get(cityId); markerEntry.element.hidden = !visibleCityIds.has(cityId); } if (options.freezeCityLabelLayout) { preserveCityLabelPlacements(visibleEntries, stageProjection); } else { resolveCityLabelCollisions(visibleEntries, stageProjection); } } function createCityMarker(city) { const element = document.createElement("div"); element.className = "city-html-marker"; element.tabIndex = 0; element.setAttribute("role", "button"); element.setAttribute("aria-label", pointLabel(city)); const halo = document.createElement("span"); halo.className = "city-html-marker__halo"; const pointElement = document.createElement("span"); pointElement.className = "city-html-marker__point"; const label = document.createElement("span"); label.className = "city-html-marker__label"; const zh = document.createElement("span"); zh.className = "city-html-marker__zh"; zh.textContent = city.cityZh || city.cityEn || ""; const en = document.createElement("span"); en.className = "city-html-marker__en"; en.textContent = city.cityEn && city.cityEn !== city.cityZh ? city.cityEn : ""; en.hidden = !en.textContent; label.append(zh, en); element.append(halo, pointElement, label); element.addEventListener("pointerenter", () => { state.hoverPoint = city; element.classList.add("is-hovered"); warmBookCoversForPoint(city, { priority: "high" }); }); element.addEventListener("pointerleave", () => { if (state.hoverPoint?.id === city.id) state.hoverPoint = null; element.classList.remove("is-hovered"); }); element.addEventListener("focus", () => { state.hoverPoint = city; warmBookCoversForPoint(city, { priority: "high" }); }); element.addEventListener("blur", () => { if (state.hoverPoint?.id === city.id) state.hoverPoint = null; }); element.addEventListener("pointerdown", () => { warmBookCoversForPoint(city, { priority: "high" }); playUiSound("pageFlick"); }); element.addEventListener("click", () => { openCityMarker(city, "pointer"); }); element.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); playUiSound("pageFlick"); openCityMarker(city, "keyboard"); } }); const markerEntry = { element, halo, pointElement, label, zh, en, width: CITY_LABEL_METRICS.width, height: CITY_LABEL_METRICS.height, zhCenterY: CITY_LABEL_METRICS.zhCenterY, enCenterY: CITY_LABEL_METRICS.enCenterY, labelMetricsMeasured: false, point: { x: 0, y: 0 }, }; cityMarkerCache.set(city.id, markerEntry); return markerEntry; } function openCityMarker(city, inputMethod = "pointer") { if (state.activePoint?.id === city.id && !elements.cityTooltip.hidden) { closeTooltip(); return; } state.activePoint = city; trackEvent("city_marker_opened", cityAnalyticsProps(city, { source_view: "global_map", input_method: inputMethod, })); trackBookTicketOpened(city, "global_map", { input_method: inputMethod, }); syncCityMarkerActiveClasses(); syncTooltip(createProjection()); } function updateCityMarkerBase(markerEntry, city, point) { markerEntry.element.hidden = false; const special = activeSpecial(); const isSpecialCenter = Boolean(special && (city.cityId || city.id) === special.cityId); const nextClassName = `city-html-marker ${cityWeightClass(city)}${state.activePoint?.id === city.id ? " is-active" : ""}${state.hoverPoint?.id === city.id ? " is-hovered" : ""}${isSpecialCenter ? " is-special-center" : ""}`; if (markerEntry.element.className !== nextClassName) markerEntry.element.className = nextClassName; markerEntry.element.style.setProperty("--marker-x", `${point.x.toFixed(2)}px`); markerEntry.element.style.setProperty("--marker-y", `${point.y.toFixed(2)}px`); if (!markerEntry.staticMetricsApplied) { markerEntry.element.style.setProperty("--dot-size", `${cityRadius(city) * 2}px`); markerEntry.element.style.setProperty("--halo-size", `${cityHaloRadius(city) * 2}px`); markerEntry.staticMetricsApplied = true; } markerEntry.point = point; measureCityMarkerLabel(markerEntry); if (!markerEntry.labelWidthApplied) { markerEntry.element.style.setProperty("--label-width", `${markerEntry.width.toFixed(2)}px`); markerEntry.labelWidthApplied = true; } } function measureCityMarkerLabel(markerEntry) { if (markerEntry.labelMetricsMeasured) return; const labelRect = markerEntry.label.getBoundingClientRect(); markerEntry.width = labelRect.width || markerEntry.width; markerEntry.height = CITY_LABEL_METRICS.height; markerEntry.zhCenterY = CITY_LABEL_METRICS.zhCenterY; markerEntry.enCenterY = markerEntry.en.hidden ? CITY_LABEL_METRICS.zhCenterY : CITY_LABEL_METRICS.enCenterY; markerEntry.labelMetricsMeasured = true; } function resolveCityLabelCollisions(entries, projection) { const occupiedBoxes = []; const sortedEntries = [...entries].sort((a, b) => labelPriority(b.city) - labelPriority(a.city)); for (const entry of sortedEntries) { const placement = chooseLabelPlacement(entry, occupiedBoxes, projection); applyCityLabelPlacement(entry.markerEntry, placement); if (!placement.hideLabel) { occupiedBoxes.push(labelCollisionBox(placement.box, entry.city)); } } } function preserveCityLabelPlacements(entries, projection) { const occupiedBoxes = []; const sortedEntries = [...entries].sort((a, b) => labelPriority(b.city) - labelPriority(a.city)); for (const entry of sortedEntries) { if (!entry.markerEntry.labelPlacement) continue; const labelWithinGlobe = syncCityLabelEdgeVisibility(entry.markerEntry, projection); if (labelWithinGlobe) { occupiedBoxes.push(labelCollisionBox(cityLabelPlacementBox(entry.markerEntry), entry.city)); } } for (const entry of sortedEntries) { if (entry.markerEntry.labelPlacement) continue; const placement = chooseLabelPlacement(entry, occupiedBoxes, projection); applyCityLabelPlacement(entry.markerEntry, placement); if (!placement.hideLabel) { occupiedBoxes.push(labelCollisionBox(placement.box, entry.city)); } } } function chooseLabelPlacement(entry, occupiedBoxes, projection) { const candidates = labelPlacementCandidates(entry, projection); const globeSafeCandidates = candidates.filter((candidate) => candidate.labelWithinGlobe); const candidatePool = globeSafeCandidates.length ? globeSafeCandidates : candidates.map((candidate) => ({ ...candidate, hideLabel: true })); const nonOverlapping = candidatePool.filter((candidate) => { return candidate.hideLabel || !occupiedBoxes.some((box) => boxesOverlap(candidate.box, box)); }); const pool = nonOverlapping.length ? nonOverlapping : candidatePool; return [...pool].sort((a, b) => labelCandidateScore(a, occupiedBoxes) - labelCandidateScore(b, occupiedBoxes))[0]; } function labelCandidateScore(candidate, occupiedBoxes) { const overlapPenalty = collisionScore(candidate.box, occupiedBoxes) * 100; const distancePenalty = Math.max(0, candidate.distance - 54) * 24; return overlapPenalty + distancePenalty + candidate.priority * 8 + candidate.distance; } function labelPriority(city) { const guidedPlacementBoost = [ "chn-nanjing", "chn-suzhou", "chn-shanghai", "chn-hangzhou", "chn-wuhan", "chn-chongqing", "chn-jingdezhen", "chn-xian", "vnm-ho-chi-minh-city", "jpn-tokyo", "chn-qingdao", "chn-taipei", "chn-xiamen", ].includes(city.cityId || city.id) ? 220 : 0; const weight = Number(city.displayWeight || 0); const sortBias = 100 - Number(city.sortOrder || 0); return guidedPlacementBoost + weight + sortBias / 100; } function labelCollisionBox(box, city) { const weight = Number(city.displayWeight || 0); const shrink = weight >= 90 ? 1 : 7; return { left: box.left + shrink, right: box.right - shrink, top: box.top + shrink * 0.55, bottom: box.bottom - shrink * 0.55, }; } function labelPlacementCandidates(entry, projection) { const { markerEntry } = entry; const width = markerEntry.width || 62; const height = markerEntry.height || 27; const horizontalGap = 9; const zhCenterY = markerEntry.zhCenterY || 7; const enCenterY = markerEntry.en?.hidden ? zhCenterY : markerEntry.enCenterY || 19; const englishAlignedTop = -enCenterY; const chineseAlignedTop = -zhCenterY; const candidates = labelPlacementOrder(entry).map((placement, index) => { const alignTo = placement.alignTo || "en"; const baseY = alignTo === "zh" ? chineseAlignedTop : englishAlignedTop; if (placement.x != null && placement.y != null) { return { key: `${placement.key}-${index}`, x: typeof placement.x === "function" ? placement.x(width, horizontalGap) : placement.x, y: baseY + (typeof placement.y === "function" ? placement.y(height) : placement.y), align: placement.align || "left", priority: index, }; } const isRight = placement.side === "right"; const x = isRight ? horizontalGap : -horizontalGap - width; const align = isRight ? "left" : "right"; return { key: `${placement.side}-${placement.alignTo}-${placement.offset}`, x, y: baseY + placement.offset, align, priority: index, }; }); return candidates.map((candidate) => { const box = { left: markerEntry.point.x + candidate.x, right: markerEntry.point.x + candidate.x + width, top: markerEntry.point.y + candidate.y, bottom: markerEntry.point.y + candidate.y + height, }; return { ...candidate, distance: Math.hypot(candidate.x + width / 2, candidate.y + height / 2), box, labelWithinGlobe: cityLabelBoxWithinGlobe(box, projection), }; }); } function cityLabelBoxWithinGlobe(box, projection) { if (!projection) return true; const maxDistance = projection.radius - CITY_LABEL_EDGE_PADDING; return [ { x: box.left, y: box.top }, { x: box.right, y: box.top }, { x: box.left, y: box.bottom }, { x: box.right, y: box.bottom }, ].every((point) => Math.hypot(point.x - projection.cx, point.y - projection.cy) <= maxDistance); } function labelPlacementOrder(entry) { const cityId = entry.city.cityId || entry.city.id || ""; const fixedRightUpperPlacements = [ { side: "right", alignTo: "en", offset: 0 }, ]; const fixedLeftUpperPlacements = [ { side: "left", alignTo: "en", offset: 0 }, ]; const fixedRightLowerPlacements = [ { side: "right", alignTo: "zh", offset: 8 }, ]; const fixedLeftLowerPlacements = [ { side: "left", alignTo: "zh", offset: 8 }, ]; const rightSidePlacements = [ { side: "right", alignTo: "en", offset: 0 }, { side: "right", alignTo: "en", offset: -8 }, { side: "right", alignTo: "en", offset: 8 }, { side: "right", alignTo: "en", offset: -16 }, { side: "right", alignTo: "en", offset: 16 }, { side: "right", alignTo: "zh", offset: 8 }, ]; const leftSidePlacements = [ { side: "left", alignTo: "en", offset: 0 }, { side: "left", alignTo: "en", offset: -8 }, { side: "left", alignTo: "en", offset: 8 }, { side: "left", alignTo: "en", offset: -16 }, { side: "left", alignTo: "en", offset: 16 }, { side: "left", alignTo: "zh", offset: 8 }, ]; const wideAvoidancePlacements = [ { side: "right", alignTo: "en", offset: -22 }, { side: "right", alignTo: "en", offset: 12 }, { side: "right", alignTo: "en", offset: 22 }, { side: "left", alignTo: "en", offset: -22 }, { side: "left", alignTo: "en", offset: 22 }, ]; const deltaPlacements = { "aus-canberra": [ ...fixedRightLowerPlacements, ], "zaf-johannesburg": [ ...fixedRightLowerPlacements, ], "chn-beijing": [ ...fixedRightUpperPlacements, ], "chn-nanjing": [ { side: "right", alignTo: "en", offset: 0 }, { key: "nanjing-top-right", alignTo: "en", x: 9, y: -8, align: "left" }, ...leftSidePlacements, ], "chn-suzhou": [ { key: "suzhou-left", alignTo: "en", x: (width, gap) => -width - gap - 2, y: 0, align: "right" }, { key: "suzhou-left-up", alignTo: "en", x: (width, gap) => -width - gap - 2, y: -8, align: "right" }, { key: "suzhou-left-low", alignTo: "zh", x: (width, gap) => -width - gap - 2, y: 5, align: "right" }, ], "chn-shanghai": [ ...fixedRightUpperPlacements, ], "chn-hangzhou": [ { side: "right", alignTo: "zh", offset: 8 }, ], "chn-haikou": [ { side: "left", alignTo: "en", offset: 8 }, ], "chn-tianjin": [ { side: "left", alignTo: "en", offset: 8 }, ], "chn-jinan": [ ...fixedRightUpperPlacements, ], "chn-jingdezhen": [ { key: "jingdezhen-right-low", alignTo: "zh", x: 9, y: 5, align: "left" }, ], "chn-chengdu": [ ...fixedRightUpperPlacements, ], "vnm-hanoi": [ ...fixedRightUpperPlacements, ], "mmr-yangon": [ ...fixedRightUpperPlacements, ], "mys-george-town": [ ...fixedLeftUpperPlacements, ], "mys-kuala-lumpur": [ ...fixedLeftLowerPlacements, ], "mys-johor-bahru": [ ...fixedLeftUpperPlacements, ], "idn-medan": [ ...fixedLeftLowerPlacements, ], "kor-seoul": [ ...fixedRightLowerPlacements, ], "swe-stockholm": [ ...fixedRightUpperPlacements, ], "fra-paris": [ ...fixedRightLowerPlacements, ], "fra-marseille": [ ...fixedRightUpperPlacements, ], "cze-prague": [ ...fixedRightUpperPlacements, ], "esp-barcelona": [ ...fixedLeftLowerPlacements, ], "mar-marrakech": [ ...fixedRightLowerPlacements, ], "mar-casablanca": [ ...fixedRightLowerPlacements, ], "egy-cairo": [ ...fixedLeftUpperPlacements, ], "jor-amman": [ ...fixedRightLowerPlacements, ], "isr-tel-aviv": [ ...fixedLeftUpperPlacements, ], "isr-jerusalem": [ ...fixedLeftLowerPlacements, ], "lbn-beirut": [ ...fixedRightUpperPlacements, ], "syr-damascus": [ ...fixedRightLowerPlacements, ], "ita-rome": [ ...fixedRightLowerPlacements, ], "ita-florence": [ ...fixedRightLowerPlacements, ], "bih-sarajevo": [ ...fixedRightLowerPlacements, ], "srb-belgrade": [ ...fixedRightUpperPlacements, ], "arg-buenos-aires": [ ...fixedLeftLowerPlacements, ], "usa-cincinnati": [ ...fixedLeftLowerPlacements, ], "usa-pittsburgh": [ ...fixedLeftUpperPlacements, ], "usa-washington-d-c": [ ...fixedLeftLowerPlacements, ], "usa-baltimore": [ ...fixedRightLowerPlacements, ], "chn-guangzhou": [ { side: "left", alignTo: "en", offset: 0 }, ], "chn-shenzhen": [ ...fixedRightUpperPlacements, ], "chn-hong-kong": [ { side: "right", alignTo: "zh", offset: 8 }, ], "chn-macau": [ { side: "left", alignTo: "zh", offset: 8 }, ], "chn-wuhan": [ ...fixedRightUpperPlacements, ], "chn-chongqing": [ { key: "chongqing-right-low", alignTo: "zh", x: 9, y: 5, align: "left" }, ], "chn-xian": [ { key: "xian-right-up", alignTo: "en", x: 8, y: -2, align: "left" }, ], "vnm-ho-chi-minh-city": [ { key: "ho-chi-minh-right-up", alignTo: "en", x: 8, y: -4, align: "left" }, ], "jpn-tokyo": [ ...fixedRightUpperPlacements, ], "jpn-kyoto": [ ...fixedLeftUpperPlacements, ], "jpn-osaka": [ ...fixedLeftLowerPlacements, ], "jpn-kamakura": [ ...fixedLeftLowerPlacements, ], "chn-qingdao": [ ...fixedRightUpperPlacements, ], "chn-taipei": [ ...fixedRightUpperPlacements, ], "chn-xiamen": [ ...fixedRightLowerPlacements, ], "chn-quanzhou": [ ...fixedLeftUpperPlacements, ], "nld-amsterdam": [ ...fixedRightUpperPlacements, ], "bel-antwerp": [ { side: "right", alignTo: "zh", offset: 8 }, { side: "right", alignTo: "zh", offset: 12 }, { side: "right", alignTo: "en", offset: 16 }, ], "vat-vatican-city": [ ...fixedLeftUpperPlacements, ], }; if (deltaPlacements[cityId]) { return deltaPlacements[cityId]; } return [...rightSidePlacements, ...leftSidePlacements, ...wideAvoidancePlacements]; } function applyCityLabelPlacement(markerEntry, placement) { markerEntry.labelPlacement = placement; markerEntry.labelPlacementKey = placement.key; markerEntry.element.classList.toggle("is-label-outside-globe", Boolean(placement.hideLabel)); markerEntry.element.style.setProperty("--label-x", `${placement.x.toFixed(2)}px`); markerEntry.element.style.setProperty("--label-y", `${placement.y.toFixed(2)}px`); markerEntry.element.style.setProperty("--label-align", placement.align); } function syncCityLabelEdgeVisibility(markerEntry, projection) { const placement = markerEntry.labelPlacement; if (!placement) return false; const box = cityLabelPlacementBox(markerEntry); const labelWithinGlobe = cityLabelBoxWithinGlobe(box, projection); markerEntry.element.classList.toggle("is-label-outside-globe", !labelWithinGlobe); return labelWithinGlobe; } function cityLabelPlacementBox(markerEntry) { const placement = markerEntry.labelPlacement; const width = markerEntry.width || 62; const height = markerEntry.height || CITY_LABEL_METRICS.height; return { left: markerEntry.point.x + placement.x, right: markerEntry.point.x + placement.x + width, top: markerEntry.point.y + placement.y, bottom: markerEntry.point.y + placement.y + height, }; } function collisionScore(box, occupiedBoxes) { return occupiedBoxes.reduce((score, occupiedBox) => score + overlapArea(box, occupiedBox), 0); } function overlapArea(a, b) { const xOverlap = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)); const yOverlap = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); return xOverlap * yOverlap; } function boxesOverlap(a, b) { const padding = 3; return !( a.right + padding < b.left || a.left > b.right + padding || a.bottom + padding < b.top || a.top > b.bottom + padding ); } function syncTooltip(projection, options = {}) { const city = state.activePoint; if (!city) { elements.cityTooltip.hidden = true; return; } if (state.bookCardMode === "detail") { if (!options.skipTooltipContent) updateTooltipContent(city); placeDetailTooltip(); return; } if (city.tooltipAnchor) { if (!options.skipTooltipContent) updateTooltipContent(city); placeTooltipAtAnchor(city.tooltipAnchor, city, { avoidMarker: false }); return; } const [lon, lat] = cityCoordinates(city); const [x, y, visible] = projectPoint([lon, lat], projection); if (!visible) { state.tooltipPoint = null; elements.cityTooltip.hidden = true; return; } const point = svgToStageFreePoint(x, y); if (!options.skipTooltipContent) updateTooltipContent(city); placeTooltipAtAnchor(point, city, { avoidMarker: true }); } function activeSpecial() { return mapData.specials.find((special) => special.id === state.activeSpecialId) || null; } function pointUsesSpecialBookmarkLayer(point) { const specialId = point?.specialContext?.id; if (!specialId) return false; const special = mapData.specials.find((item) => item.id === specialId) || activeSpecial(); return Boolean(special?.cityMap); } function enterSpecialView(specialId) { const special = mapData.specials.find((item) => item.id === specialId); if (!special) return; trackEvent("city_opened", cityAnalyticsProps(special.city, { source_view: "special_entry", special_id: special.id, special_name: special.specialName, })); const cityOceanTransition = special.cityMap ? captureCityOceanTransitionFromGlobe() : null; if (!state.activeSpecialId) { state.returnViewBeforeSpecial = captureCurrentGlobeView(); } state.activeSpecialId = special.id; state.activePoint = null; state.tooltipPoint = null; state.bookCardMode = "ticket"; state.detailOrigin = null; state.detailReturnTarget = null; state.activeSpecialStackKey = null; state.specialStackIndexes.clear(); state.specialStackRenderData.clear(); elements.cityTooltip.hidden = true; closeSharePreview(); const targetCenter = specialViewCenter(special); if (special.cityMap) { warmCitySpecialMapAssets(special, "high"); applyCityOceanTransitionFromGlobe(cityOceanTransition); beginCitySpecialAssetLoad(special); playUiSound("cityMapLoading"); if (state.viewAnimationFrame) cancelAnimationFrame(state.viewAnimationFrame); state.viewAnimationFrame = null; state.center = [normalizeLongitude(targetCenter[0]), clamp(targetCenter[1], -72, 72)]; state.zoom = special.focusZoom; render(); startCityOceanTransition(special); ensureSpecialDataLoaded(special).then(() => { if (state.activeSpecialId === special.id) render(); }).catch(() => {}); return; } animateView({ center: targetCenter, zoom: special.focusZoom, }); ensureSpecialDataLoaded(special).then(() => { if (state.activeSpecialId === special.id) render(); }).catch(() => {}); } function specialViewCenter(special) { const [lon, lat] = cityCoordinates(special.city); const [offsetLon = 0, offsetLat = 0] = Array.isArray(special.viewCenterOffset) ? special.viewCenterOffset : []; return [ lon + (Number.isFinite(Number(offsetLon)) ? Number(offsetLon) : 0), lat + (Number.isFinite(Number(offsetLat)) ? Number(offsetLat) : 0), ]; } function initialGlobeView() { return { center: [...INITIAL_VIEW.center], zoom: INITIAL_VIEW.zoom, }; } function captureCurrentGlobeView() { return { center: [...state.center], zoom: state.zoom, }; } function exitSpecialView(options = {}) { if (options.playSound && state.activeSpecialId) playUiSound("paperClose"); const returnView = state.returnViewBeforeSpecial ?? initialGlobeView(); state.activeSpecialId = null; state.returnViewBeforeSpecial = null; state.activePoint = null; state.tooltipPoint = null; state.bookCardMode = "ticket"; state.detailOrigin = null; state.detailReturnTarget = null; state.activeSpecialStackKey = null; state.specialStackIndexes.clear(); state.specialStackRenderData.clear(); resetCitySpecialAssetLoad(); elements.cityTooltip.hidden = true; closeSharePreview(); animateView({ center: [...returnView.center], zoom: returnView.zoom, }); } function openSpecialBook(special, book, point, index = 0) { state.bookCardMode = "detail"; state.detailOrigin = null; state.detailReturnTarget = null; closeSharePreview(); const category = specialBookCategory(book, special); const entries = specialSharePreviewEntries(special); const fallbackBook = specialBookPayload(book); const books = entries.length ? entries.map((entry) => entry.book) : [fallbackBook]; const clickedBookId = bookIdentity(fallbackBook); const orderedIndex = entries.findIndex((entry) => { const entryBookId = bookIdentity(entry.book); if (clickedBookId && entryBookId) return entryBookId === clickedBookId; return entry.sourceIndex === index; }); const bookIndex = clamp(orderedIndex >= 0 ? orderedIndex : index, 0, Math.max(books.length - 1, 0)); const pointId = `${special.id}--special-books`; state.activePoint = { ...special.city, id: pointId, books, specialContext: { id: special.id, category, categoryEn: specialBookCategoryEn(category), }, tooltipAnchor: { x: point.x, y: point.y, }, }; state.activeBookIndexes.set(pointId, bookIndex); playUiSound("bookmarkOpen"); syncTooltip(createProjection()); } function syncSpecialBackButton() { const inSpecialView = Boolean(state.activeSpecialId); const cityMap = Boolean(activeSpecial()?.cityMap); document.documentElement.classList.toggle("is-special-view", inSpecialView); document.documentElement.classList.toggle("is-special-city-map", cityMap); elements.specialBackButton.hidden = false; elements.specialBackButton.tabIndex = inSpecialView ? 0 : -1; elements.specialBackButton.setAttribute("aria-hidden", String(!inSpecialView)); elements.themeToggle.tabIndex = inSpecialView ? -1 : 0; elements.themeToggle.setAttribute("aria-hidden", String(inSpecialView)); if (elements.bookshelfToggle) { elements.bookshelfToggle.tabIndex = inSpecialView || state.bookshelfOpen ? -1 : 0; elements.bookshelfToggle.setAttribute("aria-hidden", String(inSpecialView || state.bookshelfOpen)); } } function updateTooltipContent(city) { const books = pointBooks(city); const bookIndex = activeBookIndex(city); const book = books[bookIndex] || city; const isSpecialBookDetail = Boolean(city?.specialContext); const cardCity = currentBookCardCity(city, book); warmBookCoversForPoint(city, { priority: "high" }); applyBookCardModeClass(); if (state.bookCardMode !== "detail") renderBookTicketStack(cardCity, books, bookIndex); elements.cityTooltip.querySelectorAll(".book-card__explore").forEach((explore) => { explore.hidden = state.bookCardMode !== "detail"; explore.classList.toggle("is-special-book-explore", isSpecialBookDetail); explore.querySelectorAll(".book-card__city-page").forEach((cityButton) => { cityButton.hidden = isSpecialBookDetail; cityButton.tabIndex = isSpecialBookDetail ? -1 : 0; cityButton.setAttribute("aria-hidden", String(isSpecialBookDetail)); }); }); elements.cityTooltip.querySelectorAll(".book-card__pager").forEach((pager) => { pager.hidden = books.length <= 1; }); elements.cityTooltip.querySelectorAll(".book-card__counter").forEach((counter) => { counter.textContent = `${bookIndex + 1} / ${books.length}`; }); const detailView = elements.cityTooltip.querySelector(".book-card__detail-view"); if (detailView) fillBookCard(detailView, cardCity, book); updateSaveButtons(book); warmShareHalftoneQueue(); } function currentBookCardCity(city, book) { if (!city?.specialContext) return city; const special = mapData.specials.find((item) => item.id === city.specialContext.id) || activeSpecial(); const category = specialBookCategory(book, special); return { ...city, specialContext: { ...city.specialContext, category, categoryEn: specialBookCategoryEn(category), }, }; } function renderBookTicketStack(city, books, activeIndex) { const ticketView = elements.cityTooltip.querySelector(".book-card__ticket-view"); if (!ticketView) return; const template = bookTicketTemplate(ticketView); if (!template) return; const closeButton = ticketView.querySelector(".book-card__close--float"); if (closeButton) ticketView.append(closeButton); const previousRects = captureTicketStackRects(ticketView); const shouldAnimateStack = previousRects.size > 0; ticketView.classList.toggle("is-prepping-stack-motion", shouldAnimateStack); ticketView.querySelectorAll(".book-ticket-shell, .book-ticket").forEach((ticket) => ticket.remove()); const visibleIndexes = ticketStackVisibleIndexes(books.length, activeIndex); visibleIndexes.forEach((rawIndex, index) => { warmBookCover(books[rawIndex], index === 0 ? "high" : "low"); }); const fragment = document.createDocumentFragment(); visibleIndexes.forEach((rawIndex, index) => { const book = books[rawIndex]; const shell = document.createElement("div"); shell.className = "book-ticket-shell"; shell.dataset.bookIndex = String(rawIndex); shell.classList.add("is-entering"); shell.style.setProperty("--ticket-enter-delay", `${index * 54}ms`); const ticket = template.cloneNode(true); ticket.dataset.bookIndex = String(rawIndex); ticket.dataset.bookId = book.bookId || book.id || ""; ticket.tabIndex = 0; ticket.setAttribute("role", "button"); ticket.setAttribute("aria-label", `${fullBookTitle(book, city) || "书票"},第 ${rawIndex + 1} 本`); ticket.classList.toggle("is-selected", rawIndex === activeIndex); shell.classList.toggle("is-selected", rawIndex === activeIndex); applyBookTicketStackStyle(shell, index, visibleIndexes.length); shell.append(ticket); fillBookCard(ticket, city, book); updateBookTicketSaveButton(ticket, book); if (index === 0 && closeButton) ticket.append(closeButton); fragment.append(shell); }); ticketView.append(fragment); elements.cityTooltip.classList.toggle("has-book-stack", state.bookCardMode === "ticket" && books.length > 1); elements.cityTooltip.style.setProperty("--book-stack-count", String(visibleIndexes.length)); elements.cityTooltip.style.setProperty("--book-stack-width", `${222 + (visibleIndexes.length - 1) * TICKET_STACK_PEEK}px`); scheduleTicketCutPaths(ticketView); animateBookTicketStack(ticketView, previousRects); } function ticketStackVisibleIndexes(total, activeIndex) { const safeActiveIndex = Math.max(0, Math.min(total - 1, activeIndex)); const ordered = Array.from({ length: total }, (_, index) => (safeActiveIndex + index) % total); return ordered.slice(0, TICKET_STACK_MAX_VISIBLE); } function bookCoverPreloadUrl(book) { const coverUrl = book?.coverUrl || ""; if (!coverUrl) return ""; try { return new URL(coverUrl, window.location.href).href; } catch { return String(coverUrl); } } function warmBookCover(book, priority = "low") { const url = bookCoverPreloadUrl(book); if (!url) return null; const existing = bookCoverPreloadCache.get(url); const now = performance.now(); if (existing) { if (existing.status === "failed" && priority === "high") { bookCoverPreloadCache.delete(url); } else { existing.lastUsedAt = now; if (priority === "high" && existing.image && "fetchPriority" in existing.image) { existing.image.fetchPriority = "high"; } return existing.promise; } } const image = new Image(); image.decoding = "async"; image.loading = "eager"; if ("fetchPriority" in image) image.fetchPriority = priority; const entry = { image, status: "loading", lastUsedAt: now, promise: null, }; let settled = false; entry.promise = new Promise((resolve) => { const finish = (status) => { if (settled) return; settled = true; entry.status = status; entry.lastUsedAt = performance.now(); resolve(entry); }; image.addEventListener("load", () => finish("ready"), { once: true }); image.addEventListener("error", () => finish("failed"), { once: true }); image.src = url; if (image.complete) finish(image.naturalWidth > 0 ? "ready" : "failed"); }); bookCoverPreloadCache.set(url, entry); pruneBookCoverPreloadCache(); return entry.promise; } function pruneBookCoverPreloadCache() { if (bookCoverPreloadCache.size <= BOOK_COVER_PRELOAD_LIMIT) return; const removable = Array.from(bookCoverPreloadCache.entries()) .filter(([, entry]) => entry.status !== "loading") .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt); while (bookCoverPreloadCache.size > BOOK_COVER_PRELOAD_LIMIT && removable.length) { bookCoverPreloadCache.delete(removable.shift()[0]); } } function warmBookCoversForPoint(point, options = {}) { const books = pointBooks(point); if (!books.length) return; const activeIndex = activeBookIndex(point); const visibleIndexes = ticketStackVisibleIndexes(books.length, activeIndex); visibleIndexes.forEach((bookIndex, index) => { warmBookCover(books[bookIndex], index === 0 ? (options.priority || "high") : "low"); }); if (options.includeNeighbors === false) return; for (let offset = 1; offset <= BOOK_COVER_PRELOAD_NEIGHBORS; offset += 1) { warmBookCover(books[(activeIndex + offset) % books.length], "low"); } } function captureTicketStackRects(ticketView) { const rects = new Map(); ticketStackMotionItems(ticketView).forEach((ticket) => { rects.set(ticket.dataset.bookIndex, ticket.getBoundingClientRect()); }); return rects; } function animateBookTicketStack(ticketView, previousRects) { if (!previousRects.size) { ticketView.classList.remove("is-prepping-stack-motion"); return; } const tickets = ticketStackMotionItems(ticketView); let moved = false; tickets.forEach((ticket) => { const previous = previousRects.get(ticket.dataset.bookIndex); if (!previous) return; const next = ticket.getBoundingClientRect(); const deltaX = previous.left - next.left; const deltaY = previous.top - next.top; if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) return; ticket.style.setProperty("--ticket-stack-x", `${deltaX}px`); ticket.style.setProperty("--ticket-stack-motion-y", `${deltaY}px`); ticket.classList.add("is-moving-stack"); moved = true; }); if (!moved) { ticketView.classList.remove("is-prepping-stack-motion"); return; } ticketView.getBoundingClientRect(); requestAnimationFrame(() => { ticketView.classList.remove("is-prepping-stack-motion"); tickets.forEach((ticket) => { ticket.style.setProperty("--ticket-stack-x", "0px"); ticket.style.setProperty("--ticket-stack-motion-y", "0px"); }); window.setTimeout(() => { tickets.forEach((ticket) => ticket.classList.remove("is-moving-stack")); }, 660); }); } function ticketStackMotionItems(ticketView) { const shells = Array.from(ticketView.querySelectorAll(".book-ticket-shell[data-book-index]")); if (shells.length) return shells; return Array.from(ticketView.querySelectorAll(".book-ticket[data-book-index]")); } function bookTicketTemplate(ticketView) { if (!bookTicketTemplate.cache) { const source = ticketView.querySelector(".book-ticket"); if (!source) return null; bookTicketTemplate.cache = source.cloneNode(true); } return bookTicketTemplate.cache; } function applyBookTicketStackStyle(ticket, index, count) { const selected = index === 0; const stackLift = [-10, -2, 6]; const lift = stackLift[index] ?? 6 + index * 6; const zIndex = count - index + (selected ? 8 : 0); const layerStrength = Math.min(index, 4); ticket.style.setProperty("--ticket-stack-lift", `${lift}px`); ticket.style.setProperty("--ticket-stack-rotate", "0deg"); ticket.style.setProperty("--ticket-stack-z", String(zIndex)); ticket.style.setProperty("--ticket-stack-overlap", index === 0 ? "0px" : `${TICKET_STACK_PEEK - 222}px`); ticket.style.setProperty("--ticket-stack-depth", String(layerStrength)); ticket.style.setProperty("--ticket-cast-alpha", `${0.13 + layerStrength * 0.025}`); ticket.style.setProperty("--ticket-cast-x", `${7 + layerStrength * 2}px`); ticket.style.setProperty("--ticket-cast-y", `${15 + layerStrength * 3}px`); } function selectBookTicket(ticket, options = {}) { const point = state.activePoint; if (!point) return; const index = Number(ticket.dataset.bookIndex); if (!Number.isFinite(index)) return; state.activeBookIndexes.set(point.id, clampBookIndex(point, index)); warmBookCoversForPoint(point, { priority: "high" }); warmShareHalftoneQueue(); trackBookTicketOpened(point, "book_ticket_stack", { input_method: options.rerender ? "pointer" : "keyboard", }); if (options.rerender) { playUiSound("pageFlick"); updateTooltipContent(point); } } function shouldKeepTooltipTextSelection(event) { const pointer = state.tooltipPointer; state.tooltipPointer = null; const movedDistance = pointer ? Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) : 0; return movedDistance > 4 || hasTextSelectionInside(elements.cityTooltip); } function hasTextSelectionInside(root) { const selection = window.getSelection?.(); if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false; for (let index = 0; index < selection.rangeCount; index += 1) { const range = selection.getRangeAt(index); if (root.contains(range.commonAncestorContainer)) return true; } return false; } function fillBookCard(root, city, book) { const specialContext = city?.specialContext; root.classList.toggle("is-special-book-card", Boolean(specialContext)); if (root instanceof Element) { root.dataset.bookId = book.bookId || book.id || ""; } root.querySelectorAll?.(".book-ticket, .share-ticket").forEach((ticket) => { ticket.dataset.bookId = book.bookId || book.id || ""; }); const title = fullBookTitle(book, city); const coverUrl = book.coverUrl || ""; const coverImages = Array.from(root.querySelectorAll(".book-ticket__cover img, .book-card__detail-cover img, .share-ticket__cover img")); const coverPriority = root.matches?.(".book-ticket") && !root.classList.contains("is-selected") ? "low" : "high"; let pendingCovers = coverUrl ? coverImages.length : 0; const coverToken = `${bookIdentity(book)}:${coverUrl}`; setBookCardCoverPending(root, pendingCovers > 0); root.querySelectorAll(".book-card__cover-fallback").forEach((fallback) => { fallback.hidden = Boolean(coverUrl); }); const finishCover = (cover, loaded) => { if (cover.dataset.coverToken !== coverToken) return; if (cover.dataset.coverReadyToken === coverToken) return; cover.dataset.coverReadyToken = coverToken; if (!loaded) { cover.hidden = true; root.querySelectorAll(".book-card__cover-fallback").forEach((fallback) => { fallback.hidden = false; }); } scheduleTicketCutPaths(root); scheduleDetailCoverFit(root); scheduleActiveTooltipPlacement(); pendingCovers -= 1; if (pendingCovers <= 0) { setBookCardCoverPending(root, false); revealBookTicketShellWhenReady(root); } }; coverImages.forEach((cover) => { cover.dataset.coverToken = coverToken; cover.alt = title ? `${title}封面` : ""; cover.hidden = !coverUrl; if (!coverUrl) { cover.removeAttribute("src"); return; } cover.decoding = "async"; cover.loading = "eager"; if ("fetchPriority" in cover) cover.fetchPriority = coverPriority; cover.addEventListener("error", () => finishCover(cover, false), { once: true }); cover.addEventListener("load", () => { finishCover(cover, true); }, { once: true }); cover.decode?.().then(() => { finishCover(cover, true); }).catch(() => {}); cover.src = coverUrl; if (cover.complete) finishCover(cover, cover.naturalWidth > 0); }); root.querySelectorAll(".book-card__location-zh").forEach((element) => { element.textContent = bookCardLocationLine(city); }); root.querySelectorAll(".book-card__location-en").forEach((element) => { element.textContent = ""; element.hidden = !element.textContent; }); root.querySelectorAll(".book-card__title-zh").forEach((element) => { renderBookTitle(element, titleLayoutForBook(book, city)); }); root.querySelectorAll(".book-card__title-original").forEach((element) => { element.textContent = book.titleOriginal || ""; element.hidden = !element.textContent; element.classList.remove("is-share-title-original-long"); element.style.removeProperty("--detail-title-original-scale"); element.style.removeProperty("--share-title-original-scale"); }); root.querySelectorAll(".book-card__meta").forEach((element) => { renderBookMeta(element, book, { split: false }); }); root.querySelectorAll(".book-card__shareline").forEach((element) => { const text = shortBookText(book); element.textContent = text; element.hidden = !text; }); root.querySelectorAll(".book-card__intro").forEach((element) => { renderDetailBookIntro(element, book); }); root.querySelectorAll(".book-card__quote").forEach((element) => { const text = shortBookText(book); element.textContent = text; element.hidden = !text; }); scheduleBookMetaFit(root); scheduleTicketCutPaths(root); scheduleDetailCoverFit(root); if (pendingCovers <= 0) revealBookTicketShellWhenReady(root); } function setBookCardCoverPending(root, isPending) { root.classList.toggle("is-cover-pending", isPending); root.closest?.(".book-ticket-shell, .share-ticket-shell")?.classList.toggle("is-cover-pending", isPending); } function revealBookTicketShellWhenReady(root) { const shell = root.closest?.(".book-ticket-shell"); if (!shell || !shell.classList.contains("is-entering") || shell.classList.contains("is-cover-pending")) return; if (shell.dataset.enterQueued === "1") return; shell.dataset.enterQueued = "1"; window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { if (!shell.isConnected || shell.classList.contains("is-cover-pending")) { delete shell.dataset.enterQueued; return; } const finish = () => { shell.classList.remove("is-entering", "is-reveal-ready"); delete shell.dataset.enterQueued; shell.removeEventListener("animationend", finish); }; shell.addEventListener("animationend", finish, { once: true }); shell.classList.add("is-reveal-ready"); window.setTimeout(finish, 780); }); }); } function specialBookTicketLocationZh(city, specialContext) { const cityName = city?.cityZh || city?.cityEn || ""; return [cityName, specialContext?.category].filter(Boolean).join(" · "); } function specialBookTicketLocationEn(city, specialContext) { const cityName = city?.cityEn || city?.cityZh || ""; return [cityName, specialContext?.categoryEn].filter(Boolean).join(" · "); } function specialBookTicketLocationLine(city, specialContext) { return [ specialBookTicketLocationZh(city, specialContext), specialBookTicketLocationEn(city, specialContext), ].filter(Boolean).join(" | "); } function bookCardLocationLine(city) { const specialContext = city?.specialContext; return specialContext ? specialBookTicketLocationLine(city, specialContext) : combinedLocationHeading(city); } function scheduleBookMetaFit(root) { requestAnimationFrame(() => { requestAnimationFrame(() => { fitBookMetaLine(root); fitDetailOriginalTitle(root); fitDetailSubtitleTitle(root); fitShareOriginalTitle(root); syncDetailIntroOverflow(root); updateTicketCutPaths(root); }); }); document.fonts?.ready.then(() => { fitBookMetaLine(root); fitDetailOriginalTitle(root); fitDetailSubtitleTitle(root); fitShareOriginalTitle(root); syncDetailIntroOverflow(root); updateTicketCutPaths(root); }); } function fitDetailOriginalTitle(root) { const titles = []; if (root instanceof Element && root.matches(".book-card__detail-view")) { const title = root.querySelector(".book-card__title-original"); if (title) titles.push(title); } root.querySelectorAll?.(".book-card__detail-view .book-card__title-original").forEach((element) => titles.push(element)); titles.forEach((element) => { if (element.hidden || !element.textContent.trim() || !element.getClientRects().length) return; element.style.removeProperty("--detail-title-original-scale"); const availableWidth = element.clientWidth; const contentWidth = element.scrollWidth; if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return; const scale = Math.max(0.78, Math.min(1, availableWidth / contentWidth)); element.style.setProperty("--detail-title-original-scale", String(Math.round(scale * 1000) / 1000)); }); } function fitDetailSubtitleTitle(root) { const titles = []; if (root instanceof Element && root.matches(".book-card__detail-view")) { root.querySelectorAll(".book-card__title-zh.is-title-subtitle .book-card__title-sub").forEach((element) => titles.push(element)); } root.querySelectorAll?.(".book-card__detail-view .book-card__title-zh.is-title-subtitle .book-card__title-sub").forEach((element) => titles.push(element)); titles.forEach((element) => { if (element.hidden || !element.textContent.trim() || !element.getClientRects().length) return; element.style.removeProperty("--detail-title-sub-scale"); const availableWidth = element.parentElement?.clientWidth || element.clientWidth || 0; const contentWidth = element.scrollWidth; if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return; const minScale = element.parentElement?.classList.contains("is-title-long-subtitle") ? 0.62 : 0.72; const scale = Math.max(minScale, Math.min(1, availableWidth / contentWidth)); element.style.setProperty("--detail-title-sub-scale", String(Math.round(scale * 1000) / 1000)); }); } function fitShareOriginalTitle(root) { const titles = []; if (root instanceof Element && root.matches(".share-ticket")) { const title = root.querySelector(".book-card__title-original"); if (title) titles.push(title); } root.querySelectorAll?.(".share-ticket .book-card__title-original").forEach((element) => titles.push(element)); titles.forEach((element) => { element.classList.remove("is-share-title-original-long"); if (element.hidden || !element.textContent.trim() || !element.getClientRects().length) return; element.style.removeProperty("--share-title-original-scale"); const rawAvailableWidth = element.clientWidth || element.parentElement?.clientWidth || 0; const availableWidth = Math.max(1, rawAvailableWidth - SHARE_TITLE_ORIGINAL_SAFE_INSET); const contentWidth = element.scrollWidth; if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return; const minScale = isLongLatinShareTitle(element.textContent) ? SHARE_TITLE_ORIGINAL_LONG_LATIN_MIN_SCALE : SHARE_TITLE_ORIGINAL_MIN_SCALE; const scale = Math.max(minScale, Math.min(1, availableWidth / contentWidth)); element.style.setProperty("--share-title-original-scale", String(Math.round(scale * 1000) / 1000)); element.classList.toggle("is-share-title-original-long", scale < SHARE_TITLE_ORIGINAL_MIN_SCALE); }); } function isLongLatinShareTitle(text) { const title = String(text || "").trim(); if (title.length >= 56 && /^[\x20-\x7E]+$/.test(title)) return true; const latinLetterCount = (title.match(/[A-Za-z]/g) || []).length; return title.length >= 60 && latinLetterCount / Math.max(1, title.length) > 0.58; } const DETAIL_INTRO_FIT_STEPS = [ { font: 1, line: 1.58, gap: 16, top: 18, bottom: 8 }, { font: 0.98, line: 1.54, gap: 13, top: 15, bottom: 6 }, { font: 0.95, line: 1.5, gap: 11, top: 13, bottom: 4 }, { font: 0.92, line: 1.45, gap: 9, top: 11, bottom: 2 }, { font: 0.89, line: 1.4, gap: 7, top: 9, bottom: 0 }, { font: 0.86, line: 1.36, gap: 6, top: 8, bottom: 0 }, ]; function clearDetailIntroFit(element) { element.classList.remove("is-overflowing", "is-fit-tight"); element.style.removeProperty("--detail-intro-font-scale"); element.style.removeProperty("--detail-intro-line-height"); element.style.removeProperty("--detail-intro-gap"); element.style.removeProperty("--detail-intro-margin-top"); element.style.removeProperty("--detail-intro-margin-bottom"); } function applyDetailIntroFitStep(element, step) { element.style.setProperty("--detail-intro-font-scale", String(step.font)); element.style.setProperty("--detail-intro-line-height", String(step.line)); element.style.setProperty("--detail-intro-gap", `${step.gap}px`); element.style.setProperty("--detail-intro-margin-top", `${step.top}px`); element.style.setProperty("--detail-intro-margin-bottom", `${step.bottom}px`); } function detailIntroHasOverflow(element) { return element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1; } function syncDetailIntroOverflow(root) { const intros = []; if (root instanceof Element && root.matches(".book-card__detail-view")) { const intro = root.querySelector(".book-card__intro"); if (intro) intros.push(intro); } root.querySelectorAll?.(".book-card__detail-view .book-card__intro").forEach((element) => intros.push(element)); intros.forEach((element) => { if (!element.getClientRects().length) return; clearDetailIntroFit(element); for (const step of DETAIL_INTRO_FIT_STEPS) { applyDetailIntroFitStep(element, step); if (!detailIntroHasOverflow(element)) return; } element.classList.add("is-fit-tight"); }); } function scheduleTicketCutPaths(root = document) { requestAnimationFrame(() => { requestAnimationFrame(() => updateTicketCutPaths(root)); }); } function scheduleDetailCoverFit(root = document) { requestAnimationFrame(() => { requestAnimationFrame(() => { updateDetailCoverFit(root); if (state.bookCardMode === "detail") placeDetailTooltip(); }); }); document.fonts?.ready.then(() => { updateDetailCoverFit(root); if (state.bookCardMode === "detail") placeDetailTooltip(); }); } function scheduleActiveTooltipPlacement() { if (!state.activePoint || elements.cityTooltip.hidden) return; requestAnimationFrame(() => { requestAnimationFrame(() => { if (state.activePoint && !elements.cityTooltip.hidden) { if (state.bookCardMode === "detail") { placeDetailTooltip(); } else { syncTooltip(createProjection(), { skipTooltipContent: true }); } } }); }); } function updateDetailCoverFit(root = document) { const detailViews = []; if (root instanceof Element && root.matches(".book-card__detail-view")) { detailViews.push(root); } root.querySelectorAll?.(".book-card__detail-view").forEach((view) => detailViews.push(view)); detailViews.forEach((view) => { if (!view.getClientRects().length) return; const cover = view.querySelector(".book-card__detail-cover"); const coverImage = view.querySelector(".book-card__detail-cover img"); if (!cover || !coverImage || coverImage.hidden) return; const naturalRatio = coverImage.naturalWidth && coverImage.naturalHeight ? coverImage.naturalWidth / coverImage.naturalHeight : 0.7; const baseViewStyle = getComputedStyle(view); const computedWideCoverThreshold = Number.parseFloat(baseViewStyle.getPropertyValue("--detail-wide-cover-threshold")); const wideCoverThreshold = Number.isFinite(computedWideCoverThreshold) && computedWideCoverThreshold > 0 ? computedWideCoverThreshold : 0.94; const isWideDetailCover = naturalRatio >= wideCoverThreshold; view.classList.toggle("is-wide-detail-cover", isWideDetailCover); const viewStyle = getComputedStyle(view); const computedCoverHeight = Number.parseFloat(viewStyle.getPropertyValue("--detail-cover-height")); const computedMaxCoverRatio = Number.parseFloat(viewStyle.getPropertyValue("--detail-cover-max-ratio")); const computedWideCoverGap = Number.parseFloat(viewStyle.getPropertyValue("--detail-wide-cover-gap")); const computedSafeWidth = Number.parseFloat(viewStyle.getPropertyValue("--tooltip-safe-width")); const computedCopyMinWidth = Number.parseFloat(viewStyle.getPropertyValue("--detail-copy-min-width")); const coverHeight = Number.isFinite(computedCoverHeight) ? computedCoverHeight : Math.ceil(cover.getBoundingClientRect().height || 420); const maxCoverRatio = Number.isFinite(computedMaxCoverRatio) && computedMaxCoverRatio > 0 ? computedMaxCoverRatio : 1.12; const wideCoverGap = Number.isFinite(computedWideCoverGap) && computedWideCoverGap > 0 ? computedWideCoverGap : 40; const safeWidth = Number.isFinite(computedSafeWidth) && computedSafeWidth > 0 ? computedSafeWidth : Math.ceil(view.closest(".book-card")?.getBoundingClientRect().width || window.innerWidth - 44); const copyMinWidth = Number.isFinite(computedCopyMinWidth) && computedCopyMinWidth > 0 ? computedCopyMinWidth : 360; const columnGap = Number.parseFloat(viewStyle.columnGap) || Number.parseFloat(viewStyle.gap) || 0; const effectiveColumnGap = isWideDetailCover ? wideCoverGap : columnGap; const horizontalPadding = (Number.parseFloat(viewStyle.paddingLeft) || 0) + (Number.parseFloat(viewStyle.paddingRight) || 0); const availableCoverWidth = Math.max(96, safeWidth - copyMinWidth - effectiveColumnGap - horizontalPadding); const coverRatio = Math.min(naturalRatio, maxCoverRatio); const naturalCoverWidth = coverHeight * naturalRatio; const maxRatioCoverWidth = coverHeight * coverRatio; const desiredCoverWidth = isWideDetailCover ? naturalCoverWidth : maxRatioCoverWidth; const coverWidth = Math.max(96, Math.min(naturalCoverWidth, desiredCoverWidth, availableCoverWidth)); view.style.setProperty("--detail-cover-height", `${Math.round(coverHeight)}px`); view.style.setProperty("--detail-cover-width", `${Math.round(coverWidth)}px`); view.style.setProperty("--detail-cover-fit", isWideDetailCover ? "contain" : (naturalRatio > coverRatio ? "cover" : "contain")); }); } function updateTicketCutPaths(root = document) { ticketCutTargets(root).forEach((ticket) => { const rect = ticket.getBoundingClientRect(); if (!rect.width || !rect.height) return; const box = ticketLayoutBox(ticket, rect); const radius = ticketCutRadius(box.width); const spacingChanged = syncTicketCutSpacing(ticket, radius); if (spacingChanged) { requestAnimationFrame(() => updateTicketCutPaths(ticket)); } const updatedRect = ticket.getBoundingClientRect(); if (!updatedRect.width || !updatedRect.height) return; const updatedBox = ticketLayoutBox(ticket, updatedRect); const dividerY = ticketDividerY(ticket, updatedRect, updatedBox, radius); const path = ticketShapePath(updatedBox.width, updatedBox.height, radius, dividerY); const clipPath = `path("${path}")`; ticket.style.clipPath = clipPath; ticket.style.webkitClipPath = clipPath; ticket.style.setProperty("-webkit-clip-path", clipPath); }); } function ticketCutTargets(root) { const targets = []; if (root instanceof Element && root.matches(".book-ticket, .share-ticket, .special-book__bookmark")) { targets.push(root); } root.querySelectorAll?.(".book-ticket, .share-ticket, .special-book__bookmark").forEach((ticket) => targets.push(ticket)); return targets; } function ticketLayoutBox(ticket, rect) { return { width: ticket.offsetWidth || ticket.clientWidth || rect.width, height: ticket.offsetHeight || ticket.clientHeight || rect.height, }; } function syncTicketCutSpacing(ticket, radius) { if (ticket.matches(".special-book__bookmark")) return false; const actions = ticket.querySelector(".book-card__ticket-actions"); const actionRect = actions?.getBoundingClientRect(); const actionHeight = actionRect?.height || 0; const bodyBottomPadding = Math.round(Math.max(5, radius * 0.42)); const actionBottomGap = Math.round(Math.min(12, Math.max(radius * 0.68, actionHeight * 0.16))); const bodyPaddingChanged = setPixelProperty(ticket, "--ticket-body-bottom-padding", bodyBottomPadding); const actionGapChanged = setPixelProperty(ticket, "--ticket-action-bottom-gap", actionBottomGap); return bodyPaddingChanged || actionGapChanged; } function setPixelProperty(element, name, value) { const nextValue = `${value}px`; if (element.style.getPropertyValue(name) !== nextValue) { element.style.setProperty(name, nextValue); return true; } return false; } function ticketDividerY(ticket, rect, box, radius) { if (ticket.matches(".special-book__bookmark")) { const rule = ticket.querySelector(".special-book__rule"); const ruleRect = rule?.getBoundingClientRect(); const yScale = rect.height ? box.height / rect.height : 1; const rawDividerY = ruleRect ? (ruleRect.top + ruleRect.height / 2 - rect.top) * yScale : box.height * 0.68; return clamp(rawDividerY, radius + 1, Math.max(radius + 1, box.height - radius - 1)); } const meta = ticket.querySelector(".book-card__meta"); const actions = ticket.querySelector(".book-card__ticket-actions"); const metaRect = meta?.getBoundingClientRect(); const actionsRect = actions?.getBoundingClientRect(); const yScale = rect.height ? box.height / rect.height : 1; const rawDividerY = metaRect ? (metaRect.top - rect.top) * yScale : box.height * 0.64; const maxDividerY = actionsRect ? (actionsRect.top - rect.top) * yScale - radius - 8 : box.height - radius - 1; return clamp(rawDividerY, radius + 1, Math.max(radius + 1, maxDividerY)); } function ticketCutRadius(width) { return Math.min(TICKET_CUT_MAX_RADIUS, width * 0.044); } function ticketShapePath(width, height, radius, dividerY) { const w = roundPathNumber(width); const r = roundPathNumber(radius); const bottomY = roundPathNumber(height); const sideY = roundPathNumber(Math.min(Math.max(dividerY, r + 1), height - r - 1)); const bottomCenters = bottomCutCenters(width, radius).map(roundPathNumber).sort((a, b) => b - a); const parts = [ `M0 0`, `H${w}`, `V${roundPathNumber(sideY - r)}`, `A${r} ${r} 0 0 0 ${w} ${roundPathNumber(sideY + r)}`, `V${roundPathNumber(bottomY - r)}`, `A${r} ${r} 0 0 0 ${roundPathNumber(w - r)} ${bottomY}`, ]; bottomCenters.forEach((center) => { parts.push(`H${roundPathNumber(center + radius)}`); parts.push(`A${r} ${r} 0 0 0 ${roundPathNumber(center - radius)} ${bottomY}`); }); parts.push( `H${r}`, `A${r} ${r} 0 0 0 0 ${roundPathNumber(bottomY - r)}`, `V${roundPathNumber(sideY + r)}`, `A${r} ${r} 0 0 0 0 ${roundPathNumber(sideY - r)}`, `V0Z`, ); return parts.join(" "); } function bottomCutCenters(width, radius) { const cutCount = Math.max(7, Math.round(width / (radius * 3.3))); const spacing = width / (cutCount - 1); return Array.from({ length: cutCount - 2 }, (_, index) => spacing * (index + 1)) .filter((center) => center - radius > 0 && center + radius < width); } function roundPathNumber(value) { return Math.round(value * 10) / 10; } function fitBookMetaLine(root) { root.querySelectorAll(".book-ticket .book-card__meta, .share-ticket .book-card__meta").forEach((element) => { const authors = element.dataset.authors || ""; const details = element.dataset.details || ""; element.style.fontSize = ""; element.style.setProperty("--book-meta-scale", "1"); element.classList.remove("is-meta-split"); renderBookMeta(element, { authors: authors ? [authors] : [], publisher: details }, { split: false, fromDataset: true }); const availableWidth = element.clientWidth; const contentWidth = element.scrollWidth; if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return; renderBookMeta(element, { authors: authors ? [authors] : [], publisher: details }, { split: true, fromDataset: true }); element.classList.add("is-meta-split"); fitBookMetaAuthorLine(element); }); } function fitBookMetaAuthorLine(element) { const authorLine = element.querySelector(".book-card__meta-authors"); if (!authorLine) return; authorLine.classList.remove("is-meta-author-compressed"); authorLine.style.setProperty("--book-meta-author-scale", "1"); const styles = getComputedStyle(element); const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0; const paddingRight = Number.parseFloat(styles.paddingRight) || 0; const availableWidth = Math.max(0, element.clientWidth - paddingLeft - paddingRight - 2); const contentWidth = authorLine.scrollWidth; if (!availableWidth || !contentWidth || contentWidth <= availableWidth) return; const scale = Math.max(0.68, Math.min(1, availableWidth / contentWidth)); authorLine.style.setProperty("--book-meta-author-scale", String(Math.round(scale * 1000) / 1000)); authorLine.classList.add("is-meta-author-compressed"); } function loadSavedBooks() { try { state.savedBookRecords = normalizeSavedBookRecords(JSON.parse(localStorage.getItem(SAVED_BOOKS_STORAGE_KEY) || "[]")); } catch (error) { state.savedBookRecords = []; } syncSavedBookSet(); } function persistSavedBooks() { try { localStorage.setItem(SAVED_BOOKS_STORAGE_KEY, JSON.stringify(state.savedBookRecords)); } catch (error) { // Saved books should keep working in-memory even when browser storage is unavailable. } } function syncSavedBookSet() { state.savedBooks = new Set(state.savedBookRecords.map((record) => record.bookId)); } function openBookshelf() { state.bookshelfOpen = true; state.bookshelfHoverPointer = null; closeSharePreview({ reason: "bookshelf_opened" }); state.bookshelfPage = Math.min(state.bookshelfPage, bookshelfTotalPages() - 1); document.documentElement.classList.add("is-bookshelf-view"); syncSpecialBackButton(); trackEvent("bookshelf_opened", { shelf_count: state.savedBooks.size, page: state.bookshelfPage + 1, }); renderBookshelf(); } function closeBookshelf(options = {}) { state.bookshelfOpen = false; if (!options.preserveDetailReturn) state.detailReturnTarget = null; document.documentElement.classList.remove("is-bookshelf-view"); if (elements.bookshelfView) elements.bookshelfView.hidden = true; syncSpecialBackButton(); } function pageBookshelf(direction) { const pagination = paginateShelfItems(savedShelfItems(), { page: state.bookshelfPage + direction, pageSize: bookshelfPageSize(), }); if (pagination.page === state.bookshelfPage) return; state.bookshelfPage = pagination.page; playUiSound("pageFlick"); renderBookshelf(); } function bookshelfTotalPages() { return paginateShelfItems(savedShelfItems(), { page: state.bookshelfPage, pageSize: bookshelfPageSize(), }).totalPages; } function renderBookshelf() { if (!elements.bookshelfView || !elements.bookshelfGrid) return; const savedItems = savedShelfItems(); const pagination = paginateShelfItems(savedItems, { page: state.bookshelfPage, pageSize: bookshelfPageSize(), }); state.bookshelfPage = pagination.page; elements.bookshelfView.hidden = !state.bookshelfOpen; if (elements.bookshelfCount) { elements.bookshelfCount.textContent = String(savedItems.length); elements.bookshelfCount.hidden = savedItems.length === 0; } elements.bookshelfToggle?.classList.toggle("has-saved-books", savedItems.length > 0); elements.bookshelfToggle?.setAttribute("aria-label", savedItems.length ? `打开我的书架,已收藏 ${savedItems.length} 本` : "打开我的书架"); if (elements.bookshelfEmpty) elements.bookshelfEmpty.hidden = savedItems.length > 0; if (elements.bookshelfPager) elements.bookshelfPager.hidden = pagination.totalPages <= 1; if (elements.bookshelfPageLabel) elements.bookshelfPageLabel.textContent = `${pagination.page + 1} / ${pagination.totalPages}`; if (elements.bookshelfPrev) elements.bookshelfPrev.disabled = pagination.page <= 0; if (elements.bookshelfNext) elements.bookshelfNext.disabled = pagination.page >= pagination.totalPages - 1; const nextSignature = bookshelfRenderSignature(pagination, savedItems); if (state.bookshelfRenderSignature === nextSignature) { restoreBookshelfHoverAfterRender(); return; } state.bookshelfRenderSignature = nextSignature; const rows = buildFixedShelfRows(pagination.items, { rowSize: bookshelfRowSize(), rowCount: 2, }); elements.bookshelfGrid.replaceChildren(...rows.map(renderBookshelfRow)); restoreBookshelfHoverAfterRender(); } function bookshelfRenderSignature(pagination, savedItems) { return [ state.bookshelfOpen ? "open" : "closed", pagination.page, pagination.pageSize, pagination.totalPages, bookshelfRowSize(), savedItems.map((item) => item.book.bookId).join(","), ].join("|"); } function bookshelfPageSize() { return window.matchMedia("(max-width: 820px)").matches ? BOOKSHELF_MOBILE_PAGE_SIZE : BOOKSHELF_DESKTOP_PAGE_SIZE; } function bookshelfRowSize() { return window.matchMedia("(max-width: 820px)").matches ? BOOKSHELF_MOBILE_ROW_SIZE : BOOKSHELF_DESKTOP_ROW_SIZE; } function renderBookshelfRow(items, rowIndex) { const row = document.createElement("div"); row.className = "bookshelf-row"; row.dataset.row = String(rowIndex + 1); items.forEach((item, index) => { row.append(renderBookshelfBook(item, index)); }); return row; } function syncBookshelfHoverFromPointer(event) { state.bookshelfHoverPointer = { clientX: event.clientX, clientY: event.clientY, }; restoreBookshelfHoverFromPointer(); } function restoreBookshelfHoverAfterRender() { if (!state.bookshelfHoverPointer) return; requestAnimationFrame(restoreBookshelfHoverFromPointer); } function setupBookshelfHoverObserver() { if (!elements.bookshelfGrid || state.bookshelfHoverObserver) return; state.bookshelfHoverObserver = new MutationObserver(() => restoreBookshelfHoverAfterRender()); state.bookshelfHoverObserver.observe(elements.bookshelfGrid, { childList: true, subtree: true }); } function restoreBookshelfHoverFromPointer() { const pointer = state.bookshelfHoverPointer; if (!pointer) return; syncBookshelfHoverAtPoint(pointer.clientX, pointer.clientY); } function syncBookshelfHoverAtPoint(clientX, clientY) { const target = bookshelfBookFromPoint(clientX, clientY); elements.bookshelfGrid?.querySelectorAll(".bookshelf-book.is-hovered").forEach((book) => { if (book !== target) book.classList.remove("is-hovered"); }); target?.classList.add("is-hovered"); } function bookshelfBookFromPoint(clientX, clientY) { const directTarget = document.elementFromPoint(clientX, clientY)?.closest(".bookshelf-book"); if (directTarget) return directTarget; const books = [...(elements.bookshelfGrid?.querySelectorAll(".bookshelf-book") || [])]; return books.find((book) => { const rect = book.getBoundingClientRect(); return clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom; }) || null; } function clearBookshelfHover() { state.bookshelfHoverPointer = null; elements.bookshelfGrid?.querySelectorAll(".bookshelf-book.is-hovered").forEach((book) => { book.classList.remove("is-hovered"); }); } function renderBookshelfBook(item, index) { const button = document.createElement("button"); button.className = "bookshelf-book"; button.type = "button"; button.dataset.bookId = item.book.bookId || ""; button.style.setProperty("--book-lean", `${((index % 5) - 2) * 0.42}deg`); button.setAttribute("aria-label", `打开 ${fullBookTitle(item.book, item.city) || "收藏书籍"}`); const cover = document.createElement("span"); cover.className = "bookshelf-book__cover"; if (item.book.coverUrl) { const image = document.createElement("img"); image.src = item.book.coverUrl; image.alt = ""; image.loading = "lazy"; cover.append(image); } else { const fallback = document.createElement("span"); fallback.className = "bookshelf-book__fallback"; fallback.textContent = "BOOK"; cover.append(fallback); } const info = document.createElement("span"); info.className = "bookshelf-book__info"; const location = document.createElement("span"); location.className = "bookshelf-book__location"; location.textContent = bookshelfLocationLabel(item.city); info.append(location); button.append(cover, info); button.addEventListener("pointerenter", () => button.classList.add("is-hovered")); button.addEventListener("pointerover", () => button.classList.add("is-hovered")); button.addEventListener("mouseover", () => button.classList.add("is-hovered")); button.addEventListener("pointermove", () => button.classList.add("is-hovered")); button.addEventListener("pointerleave", () => button.classList.remove("is-hovered")); button.addEventListener("mouseleave", () => button.classList.remove("is-hovered")); button.addEventListener("focus", () => button.classList.add("is-hovered")); button.addEventListener("blur", () => button.classList.remove("is-hovered")); return button; } function openShelfBook(bookId) { const item = savedShelfItems().find((candidate) => candidate.book.bookId === bookId); if (!item) return; state.detailReturnTarget = "bookshelf"; closeBookshelf({ preserveDetailReturn: true }); playUiSound("bookmarkOpen"); state.activePoint = item.city; state.bookCardMode = "detail"; state.detailOrigin = null; state.activeBookIndexes.set(item.city.id, item.bookIndex); trackEvent("bookshelf_book_opened", bookAnalyticsProps(item.city, item.book, { source_view: "bookshelf", shelf_count: state.savedBooks.size, })); trackEvent("book_detail_opened", bookAnalyticsProps(item.city, item.book, { source_view: "bookshelf", shelf_count: state.savedBooks.size, })); render(); } function buildSearchIndex() { return mapData.cities.flatMap((city) => { const allBooks = selectedBooksForSearch(city); return allBooks.map((book, bookIndex) => buildSearchRecord(city, book, bookIndex)); }); } function selectedBooksForSearch(city) { const books = Array.isArray(city.books) && city.books.length ? city.books : [city]; return books.filter((book) => book && !book.isPlaceholder && bookIsSelected(book)); } function buildSearchRecord(city, book, bookIndex) { const title = fullBookTitle(book, city); const topics = bookSearchTopics(book, city); const topicExactAliases = bookSearchTopicExactAliases(book); const aliases = bookSearchAliases(book, city, topics); const mainText = [`《${title}》`, locationHeading(city, "zh")].filter(Boolean).join(" "); const metaText = [ searchAuthors(book), book.publisher, book.year, topics.slice(0, 4).join("、"), ].filter(Boolean).join("|"); return { id: `${city.id}--${book.bookId || book.id || bookIndex}`, city, book, bookIndex, title, topics, mainText, metaText, searchGroups: [ { type: "title", priority: 700, fields: prepareSearchableFields([title, book.titleZh, book.fullTitleZh, book.titleOriginal]) }, { type: "location", priority: 620, fields: prepareSearchableFields(searchLocationFields(city)) }, { type: "author", priority: 520, fields: prepareSearchableFields([searchAuthors(book)]) }, { type: "publisherYear", priority: 420, fields: prepareSearchableFields([book.publisher, String(book.year || "")]) }, { type: "topicExact", priority: 360, fields: prepareSearchableFields(topicExactAliases) }, { type: "topic", priority: 330, fields: prepareSearchableFields(topics) }, { type: "alias", priority: 230, fields: prepareSearchableFields(aliases) }, ], }; } function searchLocationFields(city) { const zhParts = [city.countryZh, city.adminUnitZh, city.cityZh].filter(Boolean); const enParts = [city.countryEn, city.adminUnitEn, city.cityEn].filter(Boolean); return [ city.countryZh, city.countryEn, city.adminUnitZh, city.adminUnitEn, city.cityZh, city.cityEn, zhParts.join(""), zhParts.join(" "), zhParts.join(" · "), enParts.join(" "), enParts.join(" · "), locationHeading(city, "zh"), locationHeading(city, "en"), ]; } function bookSearchTopics(book, city) { const explicit = [ book.primaryTopic, ...(Array.isArray(book.searchTopics) ? book.searchTopics : []), book["特辑分类"], ...citySearchTopics(city, book), ...(Array.isArray(book.keywords) ? book.keywords : []), ]; const derived = deriveSearchTopicsFromText([book.oneLine, book.summary, book.sourceIntro].join(" ")); return uniqueCleanTokens([...explicit, ...derived]).slice(0, 10); } function bookSearchTopicExactAliases(book) { const evidence = [ book.primaryTopic, ...(Array.isArray(book.searchTopics) ? book.searchTopics : []), book["特辑分类"], ...(Array.isArray(book.keywords) ? book.keywords : []), book.oneLine, book.summary, book.sourceIntro, ]; return deriveSearchTopicAliasMatchesFromText(evidence.join(" ")); } function citySearchTopics(city, book = {}) { const country = city?.countryZh || book?.countryZh || ""; const cityName = city?.cityZh || book?.cityZh || ""; const admin = city?.adminUnitZh || book?.adminUnitZh || ""; const tagText = [ ...(Array.isArray(city?.tags) ? city.tags : []), ...(Array.isArray(city?.categories) ? city.categories : []), ...(Array.isArray(book?.tags) ? book.tags : []), ].join(" "); const topics = []; if (["中国", "日本", "韩国"].includes(country) || /香港|澳门|台湾/.test(`${cityName} ${admin}`)) { topics.push("东亚城市"); } if (tagText.includes("technology_city")) topics.push("科技"); return topics; } function bookSearchAliases(book, city, topics) { const explicit = Array.isArray(book.searchAliases) ? book.searchAliases : []; const topicAliases = topics.flatMap((topic) => SEARCH_TOPIC_ALIASES[topic] || []); const pinyinAliases = [ ...topics.flatMap((topic) => SEARCH_PINYIN_ALIASES[topic] || []), ...(SEARCH_PINYIN_ALIASES[city.cityZh] || []), ...(SEARCH_PINYIN_ALIASES[city.countryZh] || []), ...(SEARCH_PINYIN_ALIASES[book.cityZh] || []), ...(SEARCH_PINYIN_ALIASES[book.countryZh] || []), ]; return uniqueCleanTokens([...explicit, ...topicAliases, ...pinyinAliases]); } function searchAuthors(book) { return Array.isArray(book.authors) ? book.authors.filter(Boolean).join(" / ") : ""; } function prepareSearchableFields(fields) { return uniqueCleanTokens(fields).map((raw) => { const text = String(raw); const normalized = normalizeSearchText(text); const aliasTokens = (SEARCH_PINYIN_ALIASES[text.trim()] || []).map(normalizeSearchText).filter(Boolean); return { raw: text, normalized, compact: normalized.replace(/\s+/g, ""), aliasTokens, tokens: searchTokens(text), }; }).filter((field) => field.normalized); } function uniqueCleanTokens(values) { const seen = new Set(); const output = []; values.flat().forEach((value) => { const text = String(value || "").trim(); if (!text || seen.has(text)) return; seen.add(text); output.push(text); }); return output; } function searchTokens(text) { const normalized = normalizeSearchText(text); const compact = normalized.replace(/\s+/g, ""); return uniqueCleanTokens([ normalized.split(/\s+/), compact, ...(SEARCH_PINYIN_ALIASES[String(text || "").trim()] || []), ].flat()).map(normalizeSearchText).filter(Boolean); } function searchQueryTokens(query) { const normalized = normalizeSearchText(query); const compact = normalized.replace(/\s+/g, ""); if (/\s/.test(normalized) && /^[a-z0-9]+$/.test(compact) && compact.length >= 4) { return uniqueCleanTokens([normalized, compact]).map(normalizeSearchText).filter(Boolean); } return searchTokens(normalized); } function updateSearchQuery(value, options = {}) { state.search.query = value; const query = normalizeSearchText(value); if (!query || query.length < SEARCH_MIN_QUERY_LENGTH) { cancelSearchNoResultAnalytics(); state.search.results = []; state.search.activeIndex = -1; state.search.open = Boolean(options.openIfEmpty); renderSearchResults(); scheduleSearchSuggestion(); return; } hideSearchSuggestion(); if (!state.analytics.searchStarted) { state.analytics.searchStarted = true; trackEvent("search_started", searchAnalyticsProps(query, { source_view: "search_box", })); } state.search.results = searchRecords(query).slice(0, SEARCH_RESULT_LIMIT); state.search.activeIndex = state.search.results.length ? 0 : -1; state.search.open = true; if (!state.search.results.length) { scheduleSearchNoResultAnalytics(query); } else { cancelSearchNoResultAnalytics(); } renderSearchResults(); } function scheduleSearchNoResultAnalytics(query) { cancelSearchNoResultAnalytics(); if (!shouldTrackSearchNoResultQuery(query) || state.analytics.lastNoResultQuery === query) return; state.analytics.pendingNoResultQuery = query; state.analytics.noResultTimer = window.setTimeout(() => { state.analytics.noResultTimer = null; const currentQuery = normalizeSearchText(state.search.query); if ( currentQuery !== state.analytics.pendingNoResultQuery || state.search.results.length || state.analytics.lastNoResultQuery === currentQuery || !shouldTrackSearchNoResultQuery(currentQuery) ) { return; } state.analytics.lastNoResultQuery = currentQuery; trackEvent("search_no_results", searchAnalyticsProps(currentQuery, { source_view: "search_box", query_text: analyticsSearchText(currentQuery), result_count: 0, })); }, SEARCH_NO_RESULT_ANALYTICS_IDLE_MS); } function cancelSearchNoResultAnalytics() { if (state.analytics.noResultTimer) { window.clearTimeout(state.analytics.noResultTimer); state.analytics.noResultTimer = null; } state.analytics.pendingNoResultQuery = ""; } function shouldTrackSearchNoResultQuery(query) { const text = analyticsSearchText(query); if (text.length < SEARCH_NO_RESULT_ANALYTICS_MIN_LENGTH) return false; if (/^[a-z0-9]$/iu.test(text)) return false; return true; } function scheduleSearchSuggestion() { if (state.search.suggestionTimer) { window.clearTimeout(state.search.suggestionTimer); state.search.suggestionTimer = null; } if (!shouldShowSearchSuggestion()) { if (elements.searchInput?.value.trim()) hideSearchSuggestion(); return; } state.search.suggestionTimer = window.setTimeout(() => { state.search.suggestionTimer = null; showSearchSuggestion(); }, SEARCH_SUGGESTION_IDLE_MS); } function shouldShowSearchSuggestion() { return Boolean( elements.searchInput && document.activeElement === elements.searchInput && !elements.searchInput.value.trim() && !state.search.open && !state.search.results.length && state.search.index.length, ); } function showSearchSuggestion() { if (!shouldShowSearchSuggestion()) return; const suggestion = pickSearchSuggestion(); if (!suggestion) { hideSearchSuggestion(); return; } state.search.suggestion = suggestion; state.search.suggestionRolls += 1; state.search.shownSuggestions.add(searchSuggestionKey(suggestion)); renderSearchSuggestion(); } function pickSearchSuggestion() { const fallbackOrder = ["issue", "publisher", "author"]; const preferredCategory = weightedSearchSuggestionCategory(); const categories = uniqueCleanTokens([preferredCategory, ...fallbackOrder]); for (const category of categories) { const candidate = randomSearchSuggestionFromCategory(category); if (candidate) return candidate; } state.search.shownSuggestions.clear(); for (const category of categories) { const candidate = randomSearchSuggestionFromCategory(category); if (candidate) return candidate; } return null; } function weightedSearchSuggestionCategory() { const roll = state.search.suggestionRolls; if (roll < 5) return "issue"; const weights = roll < 12 ? { issue: 85, publisher: 10, author: 5 } : { issue: 75, publisher: 15, author: 10 }; const total = Object.values(weights).reduce((sum, value) => sum + value, 0); let cursor = Math.random() * total; for (const [category, weight] of Object.entries(weights)) { cursor -= weight; if (cursor <= 0) return category; } return "issue"; } function randomSearchSuggestionFromCategory(category) { const pool = searchSuggestionPool(category) .map((label) => ({ category, label })) .filter((suggestion) => !state.search.shownSuggestions.has(searchSuggestionKey(suggestion))) .filter((suggestion) => searchRecords(normalizeSearchText(suggestion.label)).length > 0); if (!pool.length) return null; return pool[Math.floor(Math.random() * pool.length)]; } function searchSuggestionPool(category) { if (category === "publisher") return SEARCH_SUGGESTION_PUBLISHERS; if (category === "author") return SEARCH_SUGGESTION_AUTHORS; return SEARCH_SUGGESTION_ISSUES; } function searchSuggestionKey(suggestion) { return `${suggestion.category}:${suggestion.label}`; } function renderSearchSuggestion() { if (!elements.searchSuggestion) return; const suggestion = state.search.suggestion; elements.searchSuggestion.hidden = !suggestion; elements.searchSuggestion.textContent = suggestion?.label || ""; elements.searchSuggestion.dataset.suggestionCategory = suggestion?.category || ""; } function hideSearchSuggestion() { if (state.search.suggestionTimer) { window.clearTimeout(state.search.suggestionTimer); state.search.suggestionTimer = null; } state.search.suggestion = null; renderSearchSuggestion(); } function confirmSearchSuggestion() { const suggestion = state.search.suggestion; if (!suggestion || !elements.searchInput) return; playUiSound("softTap"); state.search.acceptedSuggestions += 1; const value = suggestion.label; hideSearchSuggestion(); elements.searchInput.value = value; elements.searchInput.focus(); updateSearchQuery(value); } function searchRecords(query) { const scored = state.search.index .map((record) => ({ record, match: scoreSearchRecord(record, query) })) .filter((item) => item.match.score > 0); scored.sort((a, b) => ( b.match.score - a.match.score || b.match.bestPriority - a.match.bestPriority || String(a.record.title).localeCompare(String(b.record.title), "zh-Hans-CN") )); return scored.map((item) => item.record); } function scoreSearchRecord(record, query) { const queryCompact = query.replace(/\s+/g, ""); const queryTokens = searchQueryTokens(query); const topicQuery = isSearchTopicQuery(query, queryCompact); const matches = record.searchGroups .map((group) => scoreSearchGroup(group, query, queryCompact, queryTokens, topicQuery)) .filter((item) => item.score > 0); if (!matches.length) return { score: 0, bestPriority: 0 }; const best = matches.reduce((winner, item) => (item.score > winner.score ? item : winner), matches[0]); const auxiliary = matches.reduce((total, item) => total + (item === best ? 0 : Math.min(item.score, 20)), 0); return { score: best.score + auxiliary, bestPriority: best.priority, }; } function scoreSearchGroup(group, query, queryCompact, queryTokens, topicQuery = false) { let bestScore = 0; group.fields.forEach((field) => { const score = scoreSearchField(field, query, queryCompact, queryTokens, group.type, topicQuery); bestScore = Math.max(bestScore, score); }); return { priority: group.priority, score: bestScore ? group.priority + bestScore : 0, }; } function scoreSearchField(field, query, queryCompact, queryTokens, groupType, topicQuery = false) { if (!field.normalized) return 0; if (field.normalized === query || field.compact === queryCompact) return 60; if (topicQuery && groupType === "publisherYear") return 0; if (/^[a-z0-9]{1,2}$/.test(queryCompact)) return 0; if (field.normalized.startsWith(query) || field.compact.startsWith(queryCompact)) return 46; if (field.normalized.includes(query) || field.compact.includes(queryCompact)) { return 34; } if (groupType === "publisherYear") return 0; if (/^[\u4e00-\u9fff]{1,2}$/.test(queryCompact)) return 0; const isLatinQuery = /^[a-z0-9\s]+$/.test(query) && /[a-z]/.test(queryCompact); if (isLatinQuery) { const aliasFuzzy = (field.aliasTokens || []).some((token) => fuzzySearchMatch(queryCompact, token) || fuzzySearchMatch(query, token)); if (aliasFuzzy) return 14; if (groupType !== "alias") return 0; } const fuzzy = field.tokens.some((token) => token && queryTokens.some((queryToken) => fuzzySearchMatch(queryToken, token))); return fuzzy ? 14 : 0; } function isSearchTopicQuery(query, queryCompact) { const candidates = uniqueCleanTokens([ Object.keys(SEARCH_TOPIC_ALIASES), Object.values(SEARCH_TOPIC_ALIASES).flat(), Object.values(SEARCH_TOPIC_GROUPS).flat(), ]); return candidates.some((candidate) => { const normalized = normalizeSearchText(candidate); return normalized === query || normalized.replace(/\s+/g, "") === queryCompact; }); } function fuzzySearchMatch(query, token) { if (!query || !token || query.length < 2) return false; if (query.length <= 2 || token.length <= 2) return query === token; if (token.includes(query)) return true; if (token.length >= 3 && query.includes(token)) return true; if (query.length < 3 || token.length < 3) return false; const threshold = Math.max(query.length, token.length) >= 7 ? 2 : 1; return Math.abs(query.length - token.length) <= threshold && levenshteinDistance(query, token, threshold) <= threshold; } function levenshteinDistance(a, b, maxDistance = Infinity) { const previous = Array.from({ length: b.length + 1 }, (_, index) => index); const current = new Array(b.length + 1); for (let i = 1; i <= a.length; i += 1) { current[0] = i; let rowMin = current[0]; for (let j = 1; j <= b.length; j += 1) { current[j] = Math.min( previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), ); rowMin = Math.min(rowMin, current[j]); } if (rowMin > maxDistance) return rowMin; for (let j = 0; j <= b.length; j += 1) previous[j] = current[j]; } return previous[b.length]; } function normalizeSearchText(text) { return String(text || "") .normalize("NFKC") .toLowerCase() .replace(/[《》「」『』"'“”‘’.,,。::;;//\\||·・\-—_()[\]{}]+/g, " ") .replace(/\s+/g, " ") .trim(); } function renderSearchResults() { if (!elements.searchResults || !elements.searchInput) return; const shouldShow = state.search.open && state.search.query.trim() && state.search.results.length; elements.searchResults.hidden = !shouldShow; elements.searchInput.setAttribute("aria-expanded", String(Boolean(shouldShow))); elements.searchInput.setAttribute("aria-activedescendant", shouldShow && state.search.activeIndex >= 0 ? searchResultDomId(state.search.activeIndex) : ""); if (!shouldShow) { elements.searchResults.replaceChildren(); return; } const fragment = document.createDocumentFragment(); state.search.results.forEach((result, index) => { const button = document.createElement("button"); button.type = "button"; button.id = searchResultDomId(index); button.className = "search-result"; button.dataset.resultIndex = String(index); button.setAttribute("role", "option"); button.setAttribute("aria-selected", String(index === state.search.activeIndex)); button.classList.toggle("is-active", index === state.search.activeIndex); const main = document.createElement("span"); main.className = "search-result__main"; appendHighlightedText(main, result.mainText, state.search.query); const meta = document.createElement("span"); meta.className = "search-result__meta"; appendHighlightedText(meta, searchResultMetaText(result, state.search.query), state.search.query); button.append(main, meta); fragment.append(button); }); elements.searchResults.replaceChildren(fragment); syncSearchResultsBounds(); } function searchResultDomId(index) { return `search-result-${index}`; } function syncSearchResultsBounds() { if (!elements.searchResults || elements.searchResults.hidden) return; const rect = elements.searchResults.getBoundingClientRect(); const viewportHeight = window.visualViewport?.height || window.innerHeight; const availableHeight = Math.max(126, Math.floor(viewportHeight - rect.top - 18)); elements.searchResults.style.setProperty("--search-results-max-height", `${availableHeight}px`); } function searchResultMetaText(result, query) { const metaLead = [ searchAuthors(result.book), result.book.publisher, result.book.year, ].filter(Boolean).join("|"); const topics = orderedSearchTopicsForQuery(result.topics, query).slice(0, 4).join("、"); return [metaLead, topics].filter(Boolean).join("|"); } function orderedSearchTopicsForQuery(topics, query) { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) return topics; const queryCompact = normalizedQuery.replace(/\s+/g, ""); const queryTokens = searchQueryTokens(normalizedQuery); const scored = topics.map((topic, index) => { const field = prepareSearchableFields([topic])[0]; const score = field ? scoreSearchField(field, normalizedQuery, queryCompact, queryTokens, "topic") : 0; return { topic, index, score }; }); scored.sort((a, b) => b.score - a.score || a.index - b.index); return scored.map((item) => item.topic); } function appendHighlightedText(root, text, query) { const source = String(text || ""); const ranges = highlightedRanges(source, query); let cursor = 0; ranges.forEach(([start, end]) => { if (start > cursor) root.append(document.createTextNode(source.slice(cursor, start))); const mark = document.createElement("mark"); mark.textContent = source.slice(start, end); root.append(mark); cursor = end; }); if (cursor < source.length) root.append(document.createTextNode(source.slice(cursor))); } function highlightedRanges(text, query) { const source = String(text || ""); const cleanQuery = String(query || "").trim(); if (!source || !cleanQuery) return []; const sourceLower = source.toLowerCase(); const queryLower = cleanQuery.toLowerCase(); const ranges = []; let index = sourceLower.indexOf(queryLower); while (index >= 0) { ranges.push([index, index + queryLower.length]); index = sourceLower.indexOf(queryLower, index + queryLower.length); } return ranges; } function handleSearchKeydown(event) { if (!state.search.open || !state.search.results.length) { if (event.key === "Enter") event.preventDefault(); return; } if (event.key === "ArrowDown") { event.preventDefault(); setActiveSearchResult(state.search.activeIndex + 1); } else if (event.key === "ArrowUp") { event.preventDefault(); setActiveSearchResult(state.search.activeIndex - 1); } else if (event.key === "Enter") { event.preventDefault(); activateSearchResult(state.search.activeIndex); } else if (event.key === "Escape") { closeSearchResults(); } } function setActiveSearchResult(index, options = {}) { if (!state.search.results.length) return; const nextIndex = (index + state.search.results.length) % state.search.results.length; if (nextIndex === state.search.activeIndex) return; state.search.activeIndex = nextIndex; renderSearchResults(); const active = elements.searchResults?.querySelector(`#${searchResultDomId(state.search.activeIndex)}`); if (options.scroll !== false) active?.scrollIntoView({ block: "nearest" }); } function syncSearchActiveFromPointer(event) { const option = event.target.closest(".search-result[data-result-index]"); if (!option || !elements.searchResults?.contains(option)) return; setActiveSearchResult(Number(option.dataset.resultIndex), { scroll: false }); } function activateSearchResult(index) { const result = state.search.results[index]; if (!result) return; openSearchResult(result); } function openSearchResult(result) { const resultIndex = state.search.results.indexOf(result); trackEvent("search_result_clicked", bookAnalyticsProps(result.city, result.book, searchAnalyticsProps(state.search.query, { source_view: "search_results", result_index: resultIndex, result_count: state.search.results.length, }))); playUiSound("pageFlick"); state.activeSpecialId = null; state.activeSpecialStackKey = null; state.specialStackIndexes.clear(); state.specialStackRenderData.clear(); state.bookCardMode = "ticket"; state.detailOrigin = null; state.detailReturnTarget = null; closeSharePreview(); const searchPoint = { ...result.city, searchContext: true, }; navigateToSearchResult(searchPoint, result.bookIndex, resultIndex, state.search.results.length); } function navigateToSearchResult(searchPoint, bookIndex, resultIndex, resultCount) { const navigation = state.searchNavigation; navigation.token += 1; const token = navigation.token; if (navigation.closeTimer) { clearTimeout(navigation.closeTimer); navigation.closeTimer = null; } const hasOpenBookmark = Boolean(state.activePoint && !elements.cityTooltip.hidden); if (hasOpenBookmark) { elements.cityTooltip.classList.add("is-search-transitioning-out"); elements.cityTooltip.getBoundingClientRect(); } const startNavigation = () => { if (token !== state.searchNavigation.token) return; navigation.closeTimer = null; elements.cityTooltip.classList.remove("is-search-transitioning-out"); state.activePoint = null; state.tooltipPoint = null; state.bookCardMode = "ticket"; state.detailOrigin = null; state.detailReturnTarget = null; applyBookCardModeClass(); elements.cityTooltip.hidden = true; render(); const [lon, lat] = cityCoordinates(searchPoint); const showSearchResult = () => { if (token !== state.searchNavigation.token) return; state.activePoint = searchPoint; state.activeBookIndexes.set(searchPoint.id, bookIndex); trackBookTicketOpened(searchPoint, "search_results", { result_index: resultIndex, result_count: resultCount, }); render(); }; if (Number.isFinite(lon) && Number.isFinite(lat)) { const targetZoom = Math.max(state.zoom, 1.92); animateView({ center: tooltipFocusCenterForCoordinate([lon, lat], targetZoom), zoom: targetZoom, onComplete: showSearchResult, }); } else { showSearchResult(); } }; if (hasOpenBookmark) { navigation.closeTimer = window.setTimeout(startNavigation, SEARCH_BOOKMARK_REPLACE_MS); } else { startNavigation(); } } function closeSearchResults() { state.search.open = false; state.search.activeIndex = -1; renderSearchResults(); } function savedShelfItems() { const itemsByBookId = new Map(); mapData.cities.forEach((city) => { pointBooks(city).forEach((book, bookIndex) => { if (!book?.bookId || itemsByBookId.has(book.bookId)) return; itemsByBookId.set(book.bookId, { city, book, bookIndex }); }); }); savedSpecialShelfItems().forEach((item) => { if (!item?.book?.bookId || itemsByBookId.has(item.book.bookId)) return; itemsByBookId.set(item.book.bookId, item); }); return state.savedBookRecords .map((record) => { const item = itemsByBookId.get(record.bookId); return item ? { ...item, savedAt: record.savedAt } : null; }) .filter(Boolean); } function savedSpecialShelfItems() { const items = []; mapData.specials.forEach((special) => { specialBookEntries(special).forEach((entry, bookIndex) => { const book = entry.book; if (!book?.bookId || book.isPlaceholder) return; items.push({ city: specialShelfCity(special, book, entry), book, bookIndex, }); }); }); return items; } function specialBookEntries(special) { const books = special.books.length ? special.books : [curatingPlaceholderBook(special)]; return specialBookGroups(special, books).flatMap((group) => group.entries); } function specialShelfCity(special, book, entry) { const category = specialBookCategory(book, special); return { ...special.city, id: `${special.id}--${book.bookId || entry.sourceIndex}`, books: [book], specialContext: { id: special.id, category, categoryEn: specialBookCategoryEn(category), }, }; } function updateSaveButtons(book) { const saved = state.savedBooks.has(book.bookId); elements.cityTooltip.querySelectorAll(".book-ticket[data-book-index]").forEach((ticket) => { const point = state.activePoint; const ticketBook = pointBooks(point)[clampBookIndex(point, Number(ticket.dataset.bookIndex))]; updateBookTicketSaveButton(ticket, ticketBook); }); elements.cityTooltip.querySelectorAll(".book-card__detail-view .book-card__save").forEach((saveButton) => { const label = saveButton.querySelector(".book-card__action-label"); if (label) { label.textContent = saved ? "已收藏" : "收藏"; } else { saveButton.textContent = saved ? "已收藏" : "收藏"; } saveButton.classList.toggle("is-saved", saved); }); } function updateBookTicketSaveButton(ticket, book) { const saved = Boolean(book?.bookId && state.savedBooks.has(book.bookId)); ticket.querySelectorAll(".book-card__save").forEach((saveButton) => { const label = saveButton.querySelector(".book-card__action-label"); if (label) { label.textContent = saved ? "已收藏" : "收藏"; } else { saveButton.textContent = saved ? "已收藏" : "收藏"; } saveButton.classList.toggle("is-saved", saved); }); } function toggleActiveBookSave() { const book = activeBook(state.activePoint); if (!book?.bookId) return; const willSave = !state.savedBooks.has(book.bookId); if (state.savedBooks.has(book.bookId)) { state.savedBookRecords = removeSavedBook(state.savedBookRecords, book.bookId); } else { state.savedBookRecords = upsertSavedBook(state.savedBookRecords, book.bookId); } playUiSound(willSave ? "coinCollect" : "paperClose"); syncSavedBookSet(); persistSavedBooks(); updateSaveButtons(book); renderBookshelf(); trackEvent(willSave ? "book_saved_to_shelf" : "book_removed_from_shelf", bookAnalyticsProps(state.activePoint, book, { source_view: state.bookCardMode === "detail" ? "book_detail" : "book_ticket", shelf_count_after: state.savedBooks.size, })); } function enterBookDetailMode() { const point = state.activePoint; const book = activeBook(point); if (point && book) { trackEvent("book_detail_opened", bookAnalyticsProps(point, book, { source_view: "book_ticket", })); } state.detailReturnTarget = null; state.detailOrigin = captureDetailOrigin(); state.bookCardMode = "detail"; applyBookCardModeClass(); updateActiveDetailAtFixedAnchor(); warmShareHalftoneQueue(); } function setBookCardMode(mode) { state.bookCardMode = mode; applyBookCardModeClass(); syncTooltip(createProjection()); } function applyBookCardModeClass() { elements.cityTooltip.classList.toggle("is-ticket", state.bookCardMode === "ticket"); elements.cityTooltip.classList.toggle("is-detail", state.bookCardMode === "detail"); elements.cityTooltip.classList.toggle("is-from-bookshelf-detail", state.bookCardMode === "detail" && state.detailReturnTarget === "bookshelf"); if (state.bookCardMode === "detail") { elements.cityTooltip.classList.remove("has-book-stack"); } } function openSharePreview() { const point = state.activePoint; const book = activeBook(point); if (!point || !book || !elements.sharePreview) return; trackEvent("share_opened", bookAnalyticsProps(point, book, { source_view: state.bookCardMode === "detail" ? "book_detail" : "book_ticket", })); warmShareHalftoneQueue({ immediateActive: true }); const renderPromise = renderSharePreviewBook(point, book); syncSharePreviewPager(); return renderPromise; } function renderSharePreviewBook(point, book) { if (!point || !book || !elements.sharePreview) return; const preview = elements.sharePreview; const token = `${bookIdentity(book)}:${(Number(preview.dataset.renderToken) || 0) + 1}`; preview.dataset.renderToken = token; preview.classList.remove("has-share-controls"); preview.classList.add("share-controls-muted"); setSharePreviewLoading(true); const stage = createSharePreviewStage(preview); const cardCity = currentBookCardCity(point, book); const prepare = async () => { let coverUrl = ""; let stagedHalftoneReady = false; let stagedHalftonePromise = null; let stagedHalftoneRequestId = ""; removeSharePreviewBackgroundProperty(stage, "--share-cover-image"); removeSharePreviewBackgroundProperty(stage, "--share-halftone-image"); delete stage.dataset.halftoneRequest; delete stage.dataset.halftoneStatus; delete stage.dataset.halftoneObjectUrl; fillBookCard(stage, cardCity, book); stage.dataset.bookId = bookIdentity(book); if (book.coverUrl) { coverUrl = shareHalftoneCoverUrl(book); setSharePreviewBackgroundProperty(stage, "--share-cover-image", `url("${coverUrl}")`); stagedHalftoneRequestId = shareHalftoneRequestId(book, coverUrl); stagedHalftonePromise = applyShareHalftoneBackground(book, coverUrl, stage, { requestId: stagedHalftoneRequestId, priority: SHARE_HALFTONE_PRIORITY.visible, }); } await waitForSharePreviewImages(stage); if (document.fonts?.ready) await document.fonts.ready; await nextAnimationFrame(); await nextAnimationFrame(); fitBookMetaLine(stage); fitShareOriginalTitle(stage); updateTicketCutPaths(stage); if (stagedHalftonePromise) { stagedHalftoneReady = await waitForInitialShareHalftone( stagedHalftonePromise, stage, stagedHalftoneRequestId, SHARE_HALFTONE_REVEAL_WAIT_MS, ); } if (preview.dataset.renderToken !== token) return false; commitSharePreviewStage(preview, stage); preview.hidden = false; if (coverUrl && !stagedHalftoneReady) void applyShareHalftoneBackground(book, coverUrl, preview); return true; }; return prepare() .catch((error) => { console.warn("Share preview preparation failed", error); if (preview.dataset.renderToken === token) preview.hidden = false; return false; }) .finally(() => { stage.remove(); if (preview.dataset.renderToken === token) setSharePreviewLoading(false); }); } function createSharePreviewStage(preview) { const stage = preview.cloneNode(true); stage.removeAttribute("id"); stage.hidden = false; stage.classList.add("share-preview--staging"); stage.setAttribute("aria-hidden", "true"); document.body.append(stage); return stage; } function waitForSharePreviewImages(root) { const images = [...root.querySelectorAll(".share-ticket__cover img")].filter((image) => !image.hidden); return Promise.all(images.map(async (image) => { if (image.complete && image.naturalWidth) { await image.decode?.().catch(() => {}); return; } return new Promise((resolve) => { image.addEventListener("load", async () => { await image.decode?.().catch(() => {}); resolve(); }, { once: true }); image.addEventListener("error", resolve, { once: true }); }); })); } function commitSharePreviewStage(preview, stage) { revokeShareHalftoneObjectUrl(preview); preview.innerHTML = stage.innerHTML; preview.classList.toggle("is-special-book-card", stage.classList.contains("is-special-book-card")); preview.classList.remove("has-share-controls"); preview.classList.add("share-controls-muted"); preview.classList.add("is-revealing"); const revealToken = preview.dataset.renderToken; preview.dataset.revealToken = revealToken; window.setTimeout(() => { if (preview.dataset.revealToken === revealToken) preview.classList.remove("is-revealing"); }, 240); preview.dataset.bookId = stage.dataset.bookId || ""; ["halftoneRequest", "halftoneStatus"].forEach((key) => { const value = stage.dataset[key]; if (value) preview.dataset[key] = value; else delete preview.dataset[key]; }); const objectUrl = stage.dataset.halftoneObjectUrl; if (objectUrl) preview.dataset.halftoneObjectUrl = objectUrl; ["--share-cover-image", "--share-halftone-image"].forEach((property) => { const value = stage.style.getPropertyValue(property); if (value) setSharePreviewBackgroundProperty(preview, property, value); else removeSharePreviewBackgroundProperty(preview, property); }); } function sharePreviewCanvasElement(preview) { return preview?.classList?.contains("share-preview__canvas") ? preview : preview?.querySelector?.(".share-preview__canvas") || null; } function setSharePreviewBackgroundProperty(preview, property, value) { preview?.style?.setProperty(property, value); const canvas = sharePreviewCanvasElement(preview); canvas?.style.setProperty(property, value); if (property === "--share-halftone-image") { applySharePreviewCanvasHalftoneBackground(canvas, value); } } function removeSharePreviewBackgroundProperty(preview, property) { preview?.style?.removeProperty(property); const canvas = sharePreviewCanvasElement(preview); canvas?.style.removeProperty(property); if (property === "--share-halftone-image") { clearSharePreviewCanvasHalftoneBackground(canvas); } } function applySharePreviewCanvasHalftoneBackground(canvas, imageValue) { if (!canvas || !imageValue) return; canvas.style.setProperty( "background-image", `${imageValue}, linear-gradient(180deg, rgba(255, 253, 247, 0.34), rgba(235, 248, 244, 0.18))`, "important", ); canvas.style.setProperty("background-position", "center, center", "important"); canvas.style.setProperty("background-size", "cover, auto", "important"); } function clearSharePreviewCanvasHalftoneBackground(canvas) { if (!canvas) return; canvas.style.removeProperty("background-image"); canvas.style.removeProperty("background-position"); canvas.style.removeProperty("background-size"); } function waitForSharePreviewReveal() { const preview = elements.sharePreview; if (!preview?.classList.contains("is-revealing")) return Promise.resolve(); return new Promise((resolve) => { window.setTimeout(resolve, 240); }); } function setSharePreviewLoading(isLoading) { const preview = elements.sharePreview; if (!preview) return; preview.classList.toggle("is-loading", isLoading); preview.setAttribute("aria-busy", String(isLoading)); preview.querySelectorAll(".share-preview__page, .share-preview__save").forEach((button) => { button.disabled = isLoading; }); } function revealSharePreviewControls() { const preview = elements.sharePreview; if (!preview) return; if (state.shareControlsHideTimer) { window.clearTimeout(state.shareControlsHideTimer); state.shareControlsHideTimer = null; } preview.classList.remove("share-controls-muted"); preview.classList.add("has-share-controls"); } function scheduleSharePreviewControlsHide() { const preview = elements.sharePreview; if (!preview) return; if (state.shareControlsHideTimer) window.clearTimeout(state.shareControlsHideTimer); state.shareControlsHideTimer = window.setTimeout(() => { preview.classList.remove("has-share-controls"); state.shareControlsHideTimer = null; }, 180); } function sharePreviewItems() { const point = state.activePoint; if (!point) return []; if (point?.specialContext) return specialSharePreviewItems(point); return globalSharePreviewItems(); } function globalSharePreviewItems() { return cityExplorePoints().flatMap((city) => pointBooks(city).map((book, bookIndex) => ({ city, book, bookIndex }))); } function specialSharePreviewItems(point) { const books = pointBooks(point); if (books.length) { return books.map((book, bookIndex) => ({ city: point, book, bookIndex })); } const special = mapData.specials.find((item) => item.id === point.specialContext.id) || activeSpecial(); const entries = specialSharePreviewEntries(special); return entries.map((entry, bookIndex) => ({ city: point, book: entry.book, bookIndex })); } function specialSharePreviewEntries(special) { if (!special) return []; const books = special.books.length ? special.books.map(specialBookPayload) : [curatingPlaceholderBook(special)]; const entries = spreadSpecialBookEntries(special, books).filter((entry) => !entry.book?.isPlaceholder); return special.cityMap ? orderCityMapBookEntries(entries, special).slice(0, SPECIAL_BOOK_OFFSETS.length) : entries; } function activeSharePreviewItemIndex(items) { const activeId = state.activePoint?.id; const activeIndex = activeBookIndex(state.activePoint); const index = items.findIndex((item) => item.city.id === activeId && item.bookIndex === activeIndex); return index >= 0 ? index : 0; } function warmShareHalftoneQueue(options = {}) { const items = sharePreviewItems(); if (!items.length) return; const activeIndex = activeSharePreviewItemIndex(items); const offsets = [0]; for (let distance = 1; distance <= SHARE_HALFTONE_PREWARM_NEIGHBORS; distance += 1) { offsets.push(distance, -distance); } const warmed = new Set(); offsets.forEach((offset) => { const index = (activeIndex + offset + items.length) % items.length; if (warmed.has(index)) return; warmed.add(index); warmShareHalftoneForBook(items[index]?.book, { immediate: offset === 0 && options.immediateActive, }); }); } function syncSharePreviewPager() { if (!elements.sharePreview) return; const canPage = sharePreviewItems().length > 1; elements.sharePreview.querySelectorAll(".share-preview__page").forEach((button) => { button.hidden = !canPage; button.tabIndex = canPage ? 0 : -1; button.setAttribute("aria-hidden", String(!canPage)); }); } function pageSharePreview(delta) { const items = sharePreviewItems(); if (items.length <= 1) return; const previousIndex = activeSharePreviewItemIndex(items); const previousItem = items[previousIndex]; const nextIndex = (previousIndex + delta + items.length) % items.length; const item = items[nextIndex]; warmShareHalftoneForBook(item.book, { immediate: true }); const usesSpecialBookmarkLayer = pointUsesSpecialBookmarkLayer(item.city); state.activePoint = item.city; state.activeBookIndexes.set(item.city.id, item.bookIndex); warmShareHalftoneQueue({ immediateActive: true }); state.bookCardMode = usesSpecialBookmarkLayer ? "detail" : "ticket"; state.detailOrigin = null; state.detailReturnTarget = null; syncCityMarkerActiveClasses(); if (usesSpecialBookmarkLayer) { updateActiveDetailAtFixedAnchor(); } else { syncTooltip(createProjection(), { skipTooltipContent: false }); } renderSharePreviewBook(item.city, item.book); syncSharePreviewPager(); trackEvent("share_preview_paged", bookAnalyticsProps(item.city, item.book, { source_view: "share_preview", direction: delta > 0 ? "next" : "previous", share_preview_index: nextIndex, share_preview_count: items.length, previous_book_id: bookIdentity(previousItem?.book), previous_city_id: previousItem?.city?.cityId || previousItem?.city?.id || "", is_special_queue: Boolean(item.city?.specialContext), })); playUiSound("pageFlick"); } async function maybeOpenShareSnapshotFromUrl() { const params = new URLSearchParams(window.location.search); const bookId = params.get("shareTicketBookId"); if (!bookId) return; document.documentElement.dataset.shareSnapshotReady = "loading"; try { const theme = params.get("theme"); if (theme === "light" || theme === "dark") { state.theme = theme; applyTheme({ syncMusic: false }); } await openShareSnapshotBook(bookId, { renderMap: true }); } catch (error) { console.error("Failed to render share snapshot", error); document.documentElement.dataset.shareSnapshotError = error.message || "Failed to render share snapshot"; document.documentElement.dataset.shareSnapshotReady = "failed"; } } async function openShareSnapshotBook(bookId, options = {}) { const item = findShareSnapshotBook(bookId); if (!item) throw new Error(`Book not found for share snapshot: ${bookId}`); state.activePoint = item.city; state.bookCardMode = "ticket"; state.activeBookIndexes.set(item.city.id, item.bookIndex); if (options.renderMap) render(); await openSharePreview(); await waitForShareSnapshotRender(10000, { halftoneMaxWait: options.halftoneMaxWait }); await nextAnimationFrame(); await nextAnimationFrame(); await waitForSharePreviewReveal(); document.documentElement.dataset.shareSnapshotReady = "ready"; } function findShareSnapshotBook(bookId) { for (const city of mapData.cities) { const books = pointBooks(city); const bookIndex = books.findIndex((book) => (book.bookId || book.id) === bookId); if (bookIndex >= 0) return { city, book: books[bookIndex], bookIndex }; } const specialItem = savedSpecialShelfItems().find((item) => (item.book?.bookId || item.book?.id) === bookId); return specialItem ? { ...specialItem, bookIndex: 0 } : null; } async function waitForShareSnapshotRender(timeout = 10000, options = {}) { const startedAt = performance.now(); const halftoneMaxWait = Number.isFinite(options.halftoneMaxWait) ? options.halftoneMaxWait : timeout; while (performance.now() - startedAt < timeout) { const elapsed = performance.now() - startedAt; const canvas = elements.sharePreview?.querySelector(".share-preview__canvas"); const halftoneReady = elements.sharePreview?.dataset.halftoneStatus !== "loading" || elapsed >= halftoneMaxWait; const rect = canvas?.getBoundingClientRect(); if (canvas && !elements.sharePreview.hidden && halftoneReady && rect.width > 0 && rect.height > 0) { await waitForSnapshotAssets(canvas); return; } await nextAnimationFrame(); } throw new Error("Timed out rendering share snapshot"); } function nextAnimationFrame() { return new Promise((resolve) => requestAnimationFrame(resolve)); } function shareHalftoneRequestId(book, coverUrl) { return `${bookIdentity(book)}:${coverUrl}`; } function shareHalftoneCoverUrl(book) { return book?.coverUrl ? new URL(book.coverUrl, window.location.href).href : ""; } function warmShareHalftoneForBook(book, options = {}) { const coverUrl = shareHalftoneCoverUrl(book); if (!coverUrl) return null; const priority = options.immediate ? SHARE_HALFTONE_PRIORITY.immediate : SHARE_HALFTONE_PRIORITY.prewarm; if (options.immediate) cancelLowerPriorityShareHalftoneWork(priority, shareHalftoneRequestId(book, coverUrl)); const run = () => { const entry = shareHalftoneCacheEntry(book, coverUrl, { priority, }); entry.promise.catch(() => {}); return entry.promise; }; if (options.immediate) return run(); if (typeof window.requestIdleCallback === "function") { window.requestIdleCallback(run, { timeout: SHARE_HALFTONE_PREWARM_TIMEOUT_MS }); } else { window.setTimeout(run, 120); } return null; } function shareHalftoneCacheEntry(book, coverUrl, options = {}) { const requestId = shareHalftoneRequestId(book, coverUrl); const priority = options.priority || SHARE_HALFTONE_PRIORITY.prewarm; const existing = shareHalftoneCache.get(requestId); if (existing && existing.status !== "failed") { existing.lastUsedAt = performance.now(); promoteShareHalftoneEntry(existing, priority); return existing; } if (existing?.status === "failed") shareHalftoneCache.delete(requestId); const entry = { requestId, status: "loading", blob: null, error: null, lastUsedAt: performance.now(), priority, promise: null, }; entry.promise = loadCanvasSafeImage(coverUrl) .then(async (image) => { entry.status = "queued"; try { return await enqueueShareHalftoneRender(entry, (job) => createCoverHalftoneBlob(image, book, job)); } finally { releaseCanvasSafeImage(image); } }) .then((blob) => { entry.blob = blob; entry.status = "ready"; entry.lastUsedAt = performance.now(); return entry.blob; }) .catch((error) => { entry.status = error?.name === "AbortError" ? "cancelled" : "failed"; entry.error = error; if (entry.status === "cancelled") shareHalftoneCache.delete(requestId); throw error; }); shareHalftoneCache.set(requestId, entry); pruneShareHalftoneCache(); return entry; } function pruneShareHalftoneCache() { if (shareHalftoneCache.size <= SHARE_HALFTONE_CACHE_LIMIT) return; const entries = [...shareHalftoneCache.values()] .filter((entry) => entry.status !== "queued" && entry.status !== "loading") .sort((a, b) => (a.status === "loading") - (b.status === "loading") || a.lastUsedAt - b.lastUsedAt); while (shareHalftoneCache.size > SHARE_HALFTONE_CACHE_LIMIT && entries.length) { shareHalftoneCache.delete(entries.shift().requestId); } } function promoteShareHalftoneEntry(entry, priority) { entry.priority = Math.max(entry.priority || 0, priority || 0); if (entry.status === "queued") drainShareHalftoneRenderQueue(); } function enqueueShareHalftoneRender(entry, task) { return new Promise((resolve, reject) => { shareHalftoneRenderQueue.push({ entry, task, resolve, reject, cancelled: false }); drainShareHalftoneRenderQueue(); }); } function cancelLowerPriorityShareHalftoneWork(priority, keepRequestId = "") { for (let index = shareHalftoneRenderQueue.length - 1; index >= 0; index -= 1) { const job = shareHalftoneRenderQueue[index]; if (!job || job.entry.requestId === keepRequestId || (job.entry.priority || 0) >= priority) continue; shareHalftoneRenderQueue.splice(index, 1); cancelShareHalftoneJob(job); } shareHalftoneActiveJobs.forEach((job) => { if (!job || job.entry.requestId === keepRequestId || (job.entry.priority || 0) >= priority) return; job.cancelled = true; }); } function cancelShareHalftoneJob(job) { job.cancelled = true; job.entry.status = "cancelled"; shareHalftoneCache.delete(job.entry.requestId); job.reject(shareHalftoneCancelledError()); } function shareHalftoneCancelledError() { const error = new Error("Share halftone render superseded"); error.name = "AbortError"; return error; } function drainShareHalftoneRenderQueue() { if (shareHalftoneActiveRenders >= SHARE_HALFTONE_RENDER_CONCURRENCY) return; if (!shareHalftoneRenderQueue.length) return; shareHalftoneRenderQueue.sort((a, b) => { const priorityDiff = (b.entry.priority || 0) - (a.entry.priority || 0); return priorityDiff || b.entry.lastUsedAt - a.entry.lastUsedAt; }); const job = shareHalftoneRenderQueue.shift(); if (!job) return; shareHalftoneActiveRenders += 1; shareHalftoneActiveJobs.add(job); job.entry.status = "loading"; Promise.resolve() .then(() => job.task(job)) .then(job.resolve, job.reject) .finally(() => { shareHalftoneActiveJobs.delete(job); shareHalftoneActiveRenders -= 1; drainShareHalftoneRenderQueue(); }); } function waitForInitialShareHalftone(promise, preview, requestId, maxWait) { if (!Number.isFinite(maxWait) || maxWait <= 0) return promise.then(Boolean, () => false); return new Promise((resolve) => { let settled = false; const finish = (value) => { if (settled) return; settled = true; window.clearTimeout(timer); resolve(Boolean(value)); }; const timer = window.setTimeout(() => { if (preview?.dataset.halftoneRequest === requestId) { delete preview.dataset.halftoneRequest; preview.dataset.halftoneStatus = "deferred"; } finish(false); }, maxWait); promise.then(finish, () => finish(false)); }); } async function applyShareHalftoneBackground(book, coverUrl, preview = elements.sharePreview, options = {}) { if (!preview) return; const requestId = options.requestId || shareHalftoneRequestId(book, coverUrl); preview.dataset.halftoneRequest = requestId; preview.dataset.halftoneStatus = "loading"; try { const entry = shareHalftoneCacheEntry(book, coverUrl, { priority: options.priority || SHARE_HALFTONE_PRIORITY.visible, }); const blob = await entry.promise; if (preview.dataset.halftoneRequest !== requestId || !preview.isConnected) return false; const objectUrl = shareHalftoneObjectUrl(blob); if (preview.dataset.halftoneRequest !== requestId || !preview.isConnected) { URL.revokeObjectURL(objectUrl); return false; } revokeShareHalftoneObjectUrl(preview); preview.dataset.halftoneObjectUrl = objectUrl; setSharePreviewBackgroundProperty(preview, "--share-halftone-image", `url("${objectUrl}")`); removeSharePreviewBackgroundProperty(preview, "--share-cover-image"); preview.dataset.halftoneStatus = "ready"; return true; } catch (error) { console.warn("Share halftone background failed", error); if (preview.dataset.halftoneRequest !== requestId) return false; if (error?.name === "AbortError") return false; if (!options.retried) { shareHalftoneCache.delete(requestId); return applyShareHalftoneBackground(book, coverUrl, preview, { ...options, requestId, retried: true, }); } revokeShareHalftoneObjectUrl(preview); removeSharePreviewBackgroundProperty(preview, "--share-halftone-image"); preview.dataset.halftoneStatus = "failed"; return false; } } function shareHalftoneObjectUrl(blob) { if (blob instanceof Blob) return URL.createObjectURL(blob); const dataUrl = String(blob || ""); const [header, payload = ""] = dataUrl.split(","); const mime = header.match(/^data:([^;]+)/)?.[1] || "image/png"; const binary = atob(payload); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return URL.createObjectURL(new Blob([bytes], { type: mime })); } function revokeShareHalftoneObjectUrl(preview) { const objectUrl = preview?.dataset.halftoneObjectUrl; if (!objectUrl) return; URL.revokeObjectURL(objectUrl); delete preview.dataset.halftoneObjectUrl; } async function loadCanvasSafeImage(src, timeout = 8000) { let directError = null; try { const image = await loadCanvasImageElement(src, timeout, { crossOrigin: "anonymous" }); assertCanvasReadableImage(image, src); return image; } catch (error) { directError = error; } try { const image = await loadCanvasImageFromFetchedBlob(src, timeout); assertCanvasReadableImage(image, src); return image; } catch (error) { throw error || directError; } } function loadCanvasImageElement(src, timeout = 8000, options = {}) { return new Promise((resolve, reject) => { const image = new Image(); let settled = false; const finish = (callback, value) => { if (settled) return; settled = true; clearTimeout(timer); callback(value); }; const timer = setTimeout(() => { if (image.complete && image.naturalWidth) { finish(resolve, image); return; } finish(reject, new Error(`Timed out loading share cover: ${src}`)); }, timeout); image.onload = () => finish(resolve, image); image.onerror = () => finish(reject, new Error(`Failed to load share cover: ${src}`)); if (options.crossOrigin) image.crossOrigin = options.crossOrigin; image.src = src; if (image.complete && image.naturalWidth) { finish(resolve, image); return; } image.decode?.().then( () => finish(resolve, image), () => { if (image.complete && image.naturalWidth) finish(resolve, image); }, ); }); } async function loadCanvasImageFromFetchedBlob(src, timeout = 8000) { const controller = typeof AbortController === "function" ? new AbortController() : null; const timer = controller ? setTimeout(() => controller.abort(), timeout) : null; let objectUrl = ""; try { const response = await fetch(src, { cache: "force-cache", credentials: "same-origin", signal: controller?.signal, }); if (!response.ok) throw new Error(`Failed to fetch share cover blob: ${src}`); const blob = await response.blob(); objectUrl = URL.createObjectURL(blob); const image = await loadCanvasImageElement(objectUrl, timeout); image.__shareHalftoneObjectUrl = objectUrl; objectUrl = ""; return image; } finally { if (timer) clearTimeout(timer); if (objectUrl) URL.revokeObjectURL(objectUrl); } } function assertCanvasReadableImage(image, src) { const width = Math.max(1, Math.min(2, image.naturalWidth || image.width || 1)); const height = Math.max(1, Math.min(2, image.naturalHeight || image.height || 1)); const probeCanvas = document.createElement("canvas"); const probeContext = probeCanvas.getContext("2d", { willReadFrequently: true }); probeCanvas.width = width; probeCanvas.height = height; probeContext.drawImage(image, 0, 0, width, height); try { probeContext.getImageData(0, 0, 1, 1); } catch (error) { throw new Error(`Share cover is not canvas-readable: ${src}`, { cause: error }); } } function releaseCanvasSafeImage(image) { const objectUrl = image?.__shareHalftoneObjectUrl; if (!objectUrl) return; URL.revokeObjectURL(objectUrl); delete image.__shareHalftoneObjectUrl; } function closeSharePreview(options = {}) { if (!elements.sharePreview) return; const wasOpen = !elements.sharePreview.hidden; elements.sharePreview.dataset.renderToken = String((Number(elements.sharePreview.dataset.renderToken) || 0) + 1); elements.sharePreview.classList.remove("has-share-controls"); elements.sharePreview.classList.remove("share-controls-muted"); if (state.shareControlsHideTimer) { window.clearTimeout(state.shareControlsHideTimer); state.shareControlsHideTimer = null; } elements.sharePreview.classList.remove("is-revealing"); setSharePreviewLoading(false); const previewBookId = elements.sharePreview.dataset.bookId || ""; revokeShareHalftoneObjectUrl(elements.sharePreview); removeSharePreviewBackgroundProperty(elements.sharePreview, "--share-cover-image"); removeSharePreviewBackgroundProperty(elements.sharePreview, "--share-halftone-image"); delete elements.sharePreview.dataset.halftoneRequest; delete elements.sharePreview.dataset.halftoneStatus; elements.sharePreview.hidden = true; if (wasOpen) { const point = state.activePoint; const book = activeBook(point); const props = point && book ? bookAnalyticsProps(point, book, { source_view: "share_preview" }) : { source_view: "share_preview", book_id: previewBookId }; trackEvent("share_preview_closed", { ...props, reason: options.reason || "implicit", }); } } async function saveShareTicketImage() { const point = state.activePoint; const book = activeBook(point); if (!point || !book) return; const shareCanvas = elements.sharePreview?.querySelector(".share-preview__canvas"); if (!shareCanvas) return; let blob = null; const halftoneReady = await waitForShareHalftoneForSave(book); if (!halftoneReady) { const error = new Error("Share halftone background is not ready for export"); trackShareRenderFailed(point, book, "halftone_not_ready"); throw error; } if (supportsServerShareSnapshot()) { blob = await requestRenderedShareSnapshot(book).catch(() => null); } if (!blob) { try { await waitForSharePreviewReveal(); await waitForSnapshotAssets(shareCanvas); blob = await snapshotShareCanvasElement(shareCanvas, book); } catch (error) { trackShareRenderFailed(point, book, error.message || "snapshot_failed"); throw error; } } if (!blob) { trackShareRenderFailed(point, book, "empty_blob"); return; } downloadBlob(blob, `${slugify(fullBookTitle(book, point) || "world-citybook")}-藏书票.png`); playUiSound("saveStamp"); trackEvent("share_image_saved", bookAnalyticsProps(point, book, { source_view: "share_preview", })); } async function waitForShareHalftoneForSave(book, timeout = 30000) { const preview = elements.sharePreview; const canvas = preview?.querySelector(".share-preview__canvas"); if (!preview || !canvas) return false; const hasReadyBackground = () => ( preview.dataset.halftoneStatus === "ready" && Boolean(canvas.style.getPropertyValue("--share-halftone-image") || preview.style.getPropertyValue("--share-halftone-image")) ); if (hasReadyBackground()) return true; const coverUrl = shareHalftoneCoverUrl(book); if (!coverUrl) return false; const requestId = shareHalftoneRequestId(book, coverUrl); if (preview.dataset.halftoneStatus === "failed") { shareHalftoneCache.delete(requestId); delete preview.dataset.halftoneRequest; delete preview.dataset.halftoneStatus; removeSharePreviewBackgroundProperty(preview, "--share-halftone-image"); setSharePreviewBackgroundProperty(preview, "--share-cover-image", `url("${coverUrl}")`); } const renderPromise = applyShareHalftoneBackground(book, coverUrl, preview, { requestId, priority: SHARE_HALFTONE_PRIORITY.visible, }); const resolved = await Promise.race([ renderPromise, new Promise((resolve) => window.setTimeout(() => resolve(false), timeout)), ]); return Boolean(resolved || hasReadyBackground()); } async function requestRenderedShareSnapshot(book) { const bookId = bookIdentity(book); if (!bookId || !supportsServerShareSnapshot()) return null; const response = await fetch("/api/share-ticket-snapshot", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bookId, theme: state.theme, scale: Math.min(3, Math.max(2, window.devicePixelRatio || 2)), }), }); if (!response.ok) throw new Error(await response.text()); return response.blob(); } function supportsServerShareSnapshot() { if (window.location.protocol === "file:") return false; return ["localhost", "127.0.0.1"].includes(window.location.hostname); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); } async function waitForSnapshotAssets(root) { const images = [...root.querySelectorAll("img")]; await Promise.all(images.map(async (image) => { if (!image.currentSrc && !image.src) return; if (!image.complete || image.naturalWidth === 0) { await image.decode?.().catch(() => {}); } })); await document.fonts?.ready?.catch?.(() => {}); } async function snapshotShareCanvasElement(element, book) { const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; const width = Math.ceil(rect.width); const height = Math.ceil(rect.height); const scale = Math.min(3, Math.max(2, window.devicePixelRatio || 2)); const svg = await buildShareSnapshotDocument(element, width, height); const svgUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`; const canvas = document.createElement("canvas"); canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); const context = canvas.getContext("2d"); if (!context) return null; context.imageSmoothingEnabled = true; context.imageSmoothingQuality = "high"; context.scale(scale, scale); context.clearRect(0, 0, width, height); await paintShareSnapshotBackground(context, element, width, height); const image = await loadImage(svgUrl); context.drawImage(image, 0, 0, width, height); return new Promise((resolve) => canvas.toBlob(resolve, "image/png", 0.96)); } async function paintShareSnapshotBackground(context, element, width, height) { const style = getComputedStyle(element); const halftoneUrl = cssUrlFromValue(style.getPropertyValue("--share-halftone-image")) || cssUrlFromValue(style.backgroundImage); const coverUrl = cssUrlFromValue(style.getPropertyValue("--share-cover-image")); const imageUrl = halftoneUrl || coverUrl; context.fillStyle = "#f8f1df"; context.fillRect(0, 0, width, height); if (!imageUrl) return; const dataUrl = await snapshotResourceDataUrl(imageUrl).catch(() => null); if (!dataUrl) return; const image = await loadImage(dataUrl).catch(() => null); if (!image) return; drawCoverImage(context, image, 0, 0, width, height); } function cssUrlFromValue(value) { const match = String(value || "").match(/url\((["']?)(.*?)\1\)/); const url = match?.[2]?.trim(); return url && url !== "none" ? url : ""; } async function buildShareSnapshotDocument(element, width, height) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); svg.setAttribute("width", String(width)); svg.setAttribute("height", String(height)); svg.setAttribute("viewBox", `0 0 ${width} ${height}`); const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject"); foreignObject.setAttribute("width", "100%"); foreignObject.setAttribute("height", "100%"); const wrapper = document.createElement("div"); wrapper.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); wrapper.setAttribute("data-share-snapshot", ""); wrapper.style.width = `${width}px`; wrapper.style.height = `${height}px`; const style = document.createElement("style"); style.textContent = await snapshotShareStyles(); const clone = element.cloneNode(true); await prepareShareSnapshotClone(clone, element); wrapper.append(style, clone); foreignObject.append(wrapper); svg.append(foreignObject); return svg; } async function snapshotShareStyles() { const cssText = [...document.styleSheets].map((sheet) => { try { return [...sheet.cssRules].map((rule) => absolutizeCssUrls(rule.cssText)).join("\n"); } catch (error) { return ""; } }).join("\n"); const snapshotCss = `${cssText} [data-share-snapshot] { width: 100%; height: 100%; } [data-share-snapshot] .share-preview__canvas { width: 100% !important; height: 100% !important; margin: 0 !important; background: transparent !important; } [data-share-snapshot] .share-ticket-shell { filter: drop-shadow(0 0 18px rgba(45, 48, 43, 0.15)) drop-shadow(0 12px 32px rgba(45, 48, 43, 0.1)) !important; } [data-share-snapshot] .share-preview__canvas .share-ticket { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58) !important; filter: none !important; } [data-share-snapshot] .share-preview__canvas::before { content: none !important; display: none !important; } [data-share-snapshot] .share-preview__canvas::after { content: none !important; display: none !important; } [data-share-snapshot] .share-preview__topbar, [data-share-snapshot] .share-preview__actions { display: none !important; }`; return inlineSnapshotCssUrls(snapshotCss); } async function prepareShareSnapshotClone(clone, element) { clone.removeAttribute("id"); clone.querySelectorAll("[id]").forEach((child) => child.removeAttribute("id")); copyShareSnapshotComputedStyles(clone, element); await copyShareSnapshotCustomProperties(clone, element); await inlineShareSnapshotStyleAttributeUrls(clone); await Promise.all([...clone.querySelectorAll("img")].map(async (image) => { const src = image.currentSrc || image.src || image.getAttribute("src"); if (!src) return; image.setAttribute("src", await snapshotResourceDataUrl(src)); image.removeAttribute("srcset"); image.removeAttribute("sizes"); })); } async function inlineShareSnapshotStyleAttributeUrls(root) { const styledElements = [root, ...root.querySelectorAll("[style]")]; await Promise.all(styledElements.map(async (node) => { const styleText = node.getAttribute("style"); if (!styleText || !styleText.includes("url(")) return; node.setAttribute("style", await inlineSnapshotCssUrls(styleText)); })); } function copyShareSnapshotComputedStyles(clone, element) { const sourceElements = [element, ...element.querySelectorAll("*")]; const cloneElements = [clone, ...clone.querySelectorAll("*")]; sourceElements.forEach((sourceElement, index) => { const cloneElement = cloneElements[index]; if (!cloneElement) return; const computed = getComputedStyle(sourceElement); for (const property of computed) { cloneElement.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property)); } }); } async function copyShareSnapshotCustomProperties(clone, element) { const style = getComputedStyle(element); await Promise.all(["--share-cover-image", "--share-halftone-image"].map(async (name) => { const value = style.getPropertyValue(name).trim(); if (value) clone.style.setProperty(name, await inlineSnapshotCssUrls(value)); })); } function absolutizeCssUrls(cssText) { return String(cssText || "").replace(/url\((["']?)(.*?)\1\)/g, (_match, quote, rawUrl) => { const url = rawUrl.trim(); if (!url || /^(?:data:|blob:|https?:|#)/i.test(url)) return `url(${quote}${url}${quote})`; return `url("${absoluteSnapshotUrl(url)}")`; }); } function absoluteSnapshotUrl(url) { return new URL(url, window.location.href).href; } async function inlineSnapshotCssUrls(cssText) { const text = String(cssText || ""); const matches = [...text.matchAll(/url\((["']?)(.*?)\1\)/g)]; if (!matches.length) return text; const replacements = new Map(); await Promise.all(matches.map(async (match) => { const rawUrl = match[2].trim(); if (!rawUrl || /^(?:data:|#)/i.test(rawUrl)) return; const dataUrl = await snapshotResourceDataUrl(rawUrl).catch(() => null); if (dataUrl) replacements.set(match[0], `url("${dataUrl}")`); })); return replacements.size ? text.replace(/url\((["']?)(.*?)\1\)/g, (match) => replacements.get(match) || match) : text; } const snapshotResourceDataUrlCache = new Map(); async function snapshotResourceDataUrl(url) { if (/^data:/i.test(url)) return url; if (/^#/i.test(url)) return url; const absoluteUrl = absoluteSnapshotUrl(url); if (snapshotResourceDataUrlCache.has(absoluteUrl)) return snapshotResourceDataUrlCache.get(absoluteUrl); const promise = fetch(absoluteUrl) .then((response) => { if (!response.ok) throw new Error(`Snapshot resource failed: ${absoluteUrl}`); return response.blob(); }) .then(blobToDataUrl); snapshotResourceDataUrlCache.set(absoluteUrl, promise); return promise; } function blobToDataUrl(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } function loadImage(src) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = reject; image.src = src; }); } async function createCoverHalftoneBlob(image, book, job = null) { throwIfShareHalftoneJobCancelled(job); const width = 540; const height = 720; const renderScale = SHARE_HALFTONE_RENDER_SCALE; const sampleScale = 0.25; const sampleWidth = Math.round(width * sampleScale); const sampleHeight = Math.round(height * sampleScale); const sampleCanvas = document.createElement("canvas"); const sampleContext = sampleCanvas.getContext("2d", { willReadFrequently: true }); sampleCanvas.width = sampleWidth; sampleCanvas.height = sampleHeight; drawCoverImage(sampleContext, image, 0, 0, sampleWidth, sampleHeight); const pixels = sampleContext.getImageData(0, 0, sampleWidth, sampleHeight).data; const luminances = []; for (let index = 0; index < pixels.length; index += 4) { luminances.push((0.299 * pixels[index] + 0.587 * pixels[index + 1] + 0.114 * pixels[index + 2]) / 255); } const sortedLuminances = [...luminances].sort((a, b) => a - b); const lowLum = sortedLuminances[Math.floor(sortedLuminances.length * SHARE_HALFTONE_DOT_RULE.luminanceLow)] ?? 0; const highLum = sortedLuminances[Math.floor(sortedLuminances.length * SHARE_HALFTONE_DOT_RULE.luminanceHigh)] ?? 1; const lumRange = Math.max(0.12, highLum - lowLum); const [primaryInk, secondaryInk] = specialHalftonePalette(book) || dominantContrastHalftoneColors(pixels); const { baseInk, dotInk } = shareHalftonePlateColors(primaryInk, secondaryInk, SHARE_HALFTONE_DOT_RULE); const toneMaps = buildShareHalftoneToneMaps(pixels, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk); const rawCanvas = document.createElement("canvas"); const rawContext = rawCanvas.getContext("2d"); rawCanvas.width = width * renderScale; rawCanvas.height = height * renderScale; rawContext.scale(renderScale, renderScale); rawContext.fillStyle = `rgb(${baseInk.r}, ${baseInk.g}, ${baseInk.b})`; rawContext.fillRect(0, 0, width, height); const step = SHARE_HALFTONE_DOT_RULE.step; const dotBuckets = new Map(); let renderedRows = 0; for (let y = step / 2; y < height; y += step) { const rowOffset = Math.round(y / step) % 2 ? step * 0.5 : 0; for (let x = step / 2 + rowOffset; x < width; x += step) { const { sx, sy } = shareHalftoneSamplePoint(x, y, width, height, sampleWidth, sampleHeight); const radiusTone = shareHalftonePosterDotToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight); const noisyTone = shareHalftoneNoisyTone(radiusTone, x, y, SHARE_HALFTONE_DOT_RULE); const radius = shareHalftoneDotRadius(noisyTone, SHARE_HALFTONE_DOT_RULE); if (radius <= 0) continue; const alpha = shareHalftoneDotAlpha(noisyTone); if (alpha <= 0) continue; queueShareHalftoneDot(dotBuckets, x, y, radius, alpha); } renderedRows += 1; if (renderedRows % SHARE_HALFTONE_RENDER_YIELD_ROWS === 0) { throwIfShareHalftoneJobCancelled(job); await nextShareHalftoneRenderSlice(); } } throwIfShareHalftoneJobCancelled(job); await drawShareHalftoneDots(rawContext, dotBuckets, dotInk, job); rawContext.globalAlpha = 1; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); canvas.width = width * renderScale; canvas.height = height * renderScale; context.imageSmoothingEnabled = true; context.imageSmoothingQuality = "high"; context.clearRect(0, 0, canvas.width, canvas.height); const crop = shareHalftoneCropRect(width, height); context.drawImage( rawCanvas, crop.sx * renderScale, crop.sy * renderScale, crop.sw * renderScale, crop.sh * renderScale, 0, 0, width * renderScale, height * renderScale, ); context.globalAlpha = 1; throwIfShareHalftoneJobCancelled(job); return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (job?.cancelled) { reject(shareHalftoneCancelledError()); return; } if (blob) resolve(blob); else reject(new Error("Failed to encode share halftone background")); }, "image/png"); }); } function buildShareHalftoneToneMaps(pixels, sampleWidth, sampleHeight, lowLum, lumRange, baseInk, dotInk) { const size = sampleWidth * sampleHeight; const colorTone = new Float32Array(size); const structureTone = new Float32Array(size); const luminanceTone = new Float32Array(size); const dotLighterThanBase = dotInk.l >= baseInk.l; for (let index = 0; index < size; index += 1) { const offset = index * 4; const r = pixels[offset]; const g = pixels[offset + 1]; const b = pixels[offset + 2]; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; const normalized = clamp((luminance - lowLum) / lumRange, 0, 1); const compressed = clamp((normalized - 0.5) * SHARE_HALFTONE_DOT_RULE.contrast + 0.5, 0, 1); luminanceTone[index] = compressed; structureTone[index] = dotLighterThanBase ? compressed : 1 - compressed; colorTone[index] = shareHalftoneDotToneForPixel(r, g, b, baseInk, dotInk, compressed, SHARE_HALFTONE_DOT_RULE); } return { colorTone, structureTone, luminanceTone }; } function shareHalftonePosterDotToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight) { const index = clamp(sy, 0, sampleHeight - 1) * sampleWidth + clamp(sx, 0, sampleWidth - 1); const colorTone = toneMaps.colorTone[index]; const structureTone = toneMaps.structureTone[index]; const posterTone = shareHalftoneSmoothstep( SHARE_HALFTONE_DOT_RULE.posterThreshold - SHARE_HALFTONE_DOT_RULE.posterSoftness, SHARE_HALFTONE_DOT_RULE.posterThreshold + SHARE_HALFTONE_DOT_RULE.posterSoftness, structureTone, ); const edgeTone = shareHalftonePosterEdgeToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight); return clamp( colorTone * (1 - SHARE_HALFTONE_DOT_RULE.posterWeight) + posterTone * SHARE_HALFTONE_DOT_RULE.posterWeight + edgeTone * SHARE_HALFTONE_DOT_RULE.edgeWeight, 0, 1, ); } function shareHalftonePosterEdgeToneFromMaps(toneMaps, sx, sy, sampleWidth, sampleHeight) { const centerIndex = clamp(sy, 0, sampleHeight - 1) * sampleWidth + clamp(sx, 0, sampleWidth - 1); const centerLuminanceTone = toneMaps.luminanceTone[centerIndex]; const centerShape = toneMaps.colorTone[centerIndex]; const radius = Math.max(1, Math.round(SHARE_HALFTONE_DOT_RULE.edgeRadius || 1)); const offsets = [ [-radius, 0], [radius, 0], [0, -radius], [0, radius], ]; const maxEdge = offsets.reduce((peak, [dx, dy]) => { const x = clamp(sx + dx, 0, sampleWidth - 1); const y = clamp(sy + dy, 0, sampleHeight - 1); const index = y * sampleWidth + x; return Math.max( peak, Math.abs(centerLuminanceTone - toneMaps.luminanceTone[index]) * 0.72 + Math.abs(centerShape - toneMaps.colorTone[index]) * 0.28, ); }, 0); return clamp(maxEdge * 1.8, 0, 1); } function shareHalftoneSmoothstep(edge0, edge1, value) { const t = clamp((value - edge0) / (edge1 - edge0 || 1), 0, 1); return t * t * (3 - 2 * t); } function queueShareHalftoneDot(buckets, x, y, radius, alpha) { const radiusKey = Math.max(1, Math.round(radius * SHARE_HALFTONE_RADIUS_BUCKETS)); const alphaKey = Math.round(alpha * 100); const key = `${radiusKey}:${alphaKey}`; const bucketRadius = radiusKey / SHARE_HALFTONE_RADIUS_BUCKETS; let bucket = buckets.get(key); if (!bucket) { bucket = { alpha: alphaKey / 100, radius: bucketRadius, dots: [], }; buckets.set(key, bucket); } bucket.dots.push({ x, y }); } async function drawShareHalftoneDots(context, buckets, dotInk, job = null) { context.fillStyle = `rgb(${dotInk.r}, ${dotInk.g}, ${dotInk.b})`; if (supportsShareHalftonePath2D()) { try { let bucketIndex = 0; for (const bucket of buckets.values()) { throwIfShareHalftoneJobCancelled(job); const path = new Path2D(); bucket.dots.forEach((dot) => { path.moveTo(dot.x + bucket.radius, dot.y); path.arc(dot.x, dot.y, bucket.radius, 0, Math.PI * 2); }); context.globalAlpha = bucket.alpha; context.fill(path); bucketIndex += 1; if (bucketIndex % SHARE_HALFTONE_RENDER_YIELD_BUCKETS === 0) { await nextShareHalftoneRenderSlice(); } } return; } catch (error) { console.warn("Share halftone batched dot drawing failed; falling back", error); } } await drawShareHalftoneDotsIndividually(context, buckets, job); } async function drawShareHalftoneDotsIndividually(context, buckets, job = null) { let bucketIndex = 0; for (const bucket of buckets.values()) { throwIfShareHalftoneJobCancelled(job); context.globalAlpha = bucket.alpha; bucket.dots.forEach((dot) => { context.beginPath(); context.arc(dot.x, dot.y, bucket.radius, 0, Math.PI * 2); context.fill(); }); bucketIndex += 1; if (bucketIndex % SHARE_HALFTONE_RENDER_YIELD_BUCKETS === 0) { await nextShareHalftoneRenderSlice(); } } } function throwIfShareHalftoneJobCancelled(job) { if (job?.cancelled) throw shareHalftoneCancelledError(); } function nextShareHalftoneRenderSlice() { return new Promise((resolve) => window.setTimeout(resolve, 0)); } let shareHalftonePath2DSupported = null; function supportsShareHalftonePath2D() { if (shareHalftonePath2DSupported !== null) return shareHalftonePath2DSupported; if (typeof Path2D !== "function") { shareHalftonePath2DSupported = false; return shareHalftonePath2DSupported; } try { const canvas = document.createElement("canvas"); canvas.width = 8; canvas.height = 8; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { shareHalftonePath2DSupported = false; return shareHalftonePath2DSupported; } const path = new Path2D(); path.arc(4, 4, 3, 0, Math.PI * 2); context.fillStyle = "#000"; context.fill(path); shareHalftonePath2DSupported = context.getImageData(4, 4, 1, 1).data[3] > 0; } catch { shareHalftonePath2DSupported = false; } return shareHalftonePath2DSupported; } function dominantContrastHalftoneColors(pixels) { const buckets = new Map(); for (let index = 0; index < pixels.length; index += 16) { const r = pixels[index]; const g = pixels[index + 1]; const b = pixels[index + 2]; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; const hsl = rgbToHsl(r, g, b); if (hsl.s < 0.06 || (hsl.s < 0.1 && luminance > 0.64)) continue; const weight = 1; const hueBucket = Math.round(hsl.h / 16) * 16; const saturationBucket = Math.round(hsl.s * 6) / 6; const luminanceBucket = Math.round(hsl.l * 7) / 7; const key = `${hueBucket},${saturationBucket},${luminanceBucket}`; const bucket = buckets.get(key) || { r: 0, g: 0, b: 0, weight: 0 }; bucket.r += r * weight; bucket.g += g * weight; bucket.b += b * weight; bucket.weight += weight; buckets.set(key, bucket); } const colors = [...buckets.values()] .filter((bucket) => bucket.weight > 0) .map((bucket) => ({ r: bucket.r / bucket.weight, g: bucket.g / bucket.weight, b: bucket.b / bucket.weight, weight: bucket.weight, })) .map((color) => ({ ...color, ...rgbToHsl(color.r, color.g, color.b) })) .sort((a, b) => b.weight - a.weight); const [primarySource, secondarySource] = shareHalftoneFeatureColorPair(colors); return [ printableInkColor(primarySource || { r: 42, g: 82, b: 62 }, "primary"), printableInkColor(secondarySource || { r: 186, g: 94, b: 76 }, "secondary"), ]; } function specialHalftonePalette(book) { const colors = SHARE_HALFTONE_SPECIAL_PALETTES.get(bookIdentity(book)); if (!colors) return null; return colors.map((color, index) => printableInkColor(color, index === 0 ? "primary" : "secondary")); } function bookIdentity(book) { return book?.bookId || book?.id || ""; } function cityAnalyticsProps(city, extra = {}) { return { city_id: city?.cityId || city?.id || "", city_name: city?.cityZh || city?.cityEn || "", city_name_en: city?.cityEn || "", country: city?.countryZh || city?.countryEn || "", country_code: city?.isoA3 || city?.countryCode || "", ...extra, }; } function bookAnalyticsProps(city, book, extra = {}) { return { ...cityAnalyticsProps(city), book_id: bookIdentity(book), book_title: fullBookTitle(book, city), book_title_original: book?.titleOriginal || "", book_author: Array.isArray(book?.authors) ? book.authors.filter(Boolean).join(" / ") : "", is_special_book: Boolean(city?.specialContext), special_id: city?.specialContext?.id || "", special_category: city?.specialContext?.category || "", ...extra, }; } function searchAnalyticsProps(query, extra = {}) { const normalized = normalizeSearchText(query); return { query_length: normalized.length, query_has_chinese: /[\u4e00-\u9fff]/.test(normalized), query_has_latin: /[a-z]/.test(normalized), ...extra, }; } function analyticsSearchText(query) { return normalizeSearchText(query) .replace(/[\u0000-\u001f\u007f]/gu, "") .replace(/\s+/gu, " ") .slice(0, 80); } function analyticsAssetPath(src) { if (!src) return ""; try { const url = new URL(src, window.location.href); if (url.origin === window.location.origin) return url.pathname; return url.hostname; } catch (error) { return String(src).slice(0, 120); } } function trackBookTicketOpened(city, sourceView, extra = {}) { const book = activeBook(city); if (!city || !book) return; trackEvent("book_ticket_opened", bookAnalyticsProps(city, book, { source_view: sourceView, book_index: activeBookIndex(city), book_count: pointBooks(city).length, ...extra, })); } function trackShareRenderFailed(point, book, reason) { trackEvent("share_render_failed", bookAnalyticsProps(point, book, { source_view: "share_preview", reason, })); } function trackSpecialStackSwitched(special, group, stackKey, targetIndex, sourceView) { const entry = group.entries[targetIndex] || group.entries[0]; const book = entry?.book; trackEvent("special_stack_switched", { special_id: special.id, special_name: special.specialName || "", special_category: group.category, stack_key: stackKey, source_view: sourceView, book_index: targetIndex, stack_count: group.entries.length, book_id: bookIdentity(book), book_title: book?.titleZh || book?.titleOriginal || "", }); } function contrastHalftoneColorPair(colors) { if (!colors.length) return []; if (colors.length === 1) return [colors[0], colors[0]]; const candidates = colors.slice(0, 28); const topWeight = candidates[0]?.weight || 1; let bestPair = [candidates[0], candidates[0]]; let bestScore = -Infinity; candidates.forEach((first, firstIndex) => { candidates.slice(firstIndex + 1).forEach((second) => { const hueGap = hueDistance(first.h, second.h); if (hueGap < 42) return; const smallerArea = Math.min(first.weight, second.weight) / topWeight; if (smallerArea < 0.18 || firstIndex > 5) return; const totalArea = (first.weight + second.weight) / (topWeight * 2); const balance = Math.min(first.weight, second.weight) / Math.max(first.weight, second.weight); const contrast = Math.exp(-Math.pow((hueGap - 150) / 70, 2)); const chroma = Math.min((first.s + second.s) / 2, 0.72); const warmCoolBonus = isWarmHue(first.h) !== isWarmHue(second.h) ? 0.08 : 0; const score = totalArea * 0.56 + smallerArea * 0.16 + contrast * 0.16 + balance * 0.06 + chroma * 0.04 + warmCoolBonus; if (score > bestScore) { bestScore = score; bestPair = [first, second]; } }); }); if (bestScore < 0) return [candidates[0], candidates[0]]; return orderHalftoneColorPair(bestPair[0], bestPair[1]); } function orderHalftoneColorPair(first, second) { if (!first || !second) return [first, second]; if (first.l !== second.l) return first.l < second.l ? [first, second] : [second, first]; return first.weight >= second.weight ? [first, second] : [second, first]; } function printableInkColor(color, role = "primary") { const hsl = rgbToHsl(color.r, color.g, color.b); const saturation = hsl.s < 0.16 ? clamp(hsl.s * 0.96, 0.04, 0.16) : role === "primary" ? clamp(hsl.s * 0.94, 0.18, 0.58) : clamp(hsl.s * 0.92, 0.18, 0.56); const lightness = role === "primary" ? clamp(hsl.l - 0.04, 0.34, 0.85) : hsl.l < 0.2 ? clamp(hsl.l - 0.02, 0.08, 0.2) : clamp(hsl.l - 0.02, 0.38, 0.85); const [r, g, b] = hslToRgb(hsl.h, saturation, lightness); return { r, g, b, h: hsl.h, s: saturation, l: lightness }; } function colorSaturation(r, g, b) { const max = Math.max(r, g, b); const min = Math.min(r, g, b); return max === 0 ? 0 : (max - min) / max; } function colorDistance(a, b) { return Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b); } function rgbToHsl(r, g, b) { const red = r / 255; const green = g / 255; const blue = b / 255; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); let hue = 0; let saturation = 0; const lightness = (max + min) / 2; if (max !== min) { const delta = max - min; saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min); if (max === red) { hue = (green - blue) / delta + (green < blue ? 6 : 0); } else if (max === green) { hue = (blue - red) / delta + 2; } else { hue = (red - green) / delta + 4; } hue *= 60; } return { h: hue, s: saturation, l: lightness }; } function hslToRgb(hue, saturation, lightness) { const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; const huePrime = hue / 60; const x = chroma * (1 - Math.abs((huePrime % 2) - 1)); let red = 0; let green = 0; let blue = 0; if (huePrime < 1) { red = chroma; green = x; } else if (huePrime < 2) { red = x; green = chroma; } else if (huePrime < 3) { green = chroma; blue = x; } else if (huePrime < 4) { green = x; blue = chroma; } else if (huePrime < 5) { red = x; blue = chroma; } else { red = chroma; blue = x; } const match = lightness - chroma / 2; return [ Math.round((red + match) * 255), Math.round((green + match) * 255), Math.round((blue + match) * 255), ]; } function hueDistance(firstHue, secondHue) { const difference = Math.abs(firstHue - secondHue) % 360; return Math.min(difference, 360 - difference); } function isWarmHue(hue) { return hue <= 70 || hue >= 300; } function drawCoverImage(context, image, x, y, width, height, zoom = 1) { const sourceRatio = image.naturalWidth / image.naturalHeight; const targetRatio = width / height; let sx = 0; let sy = 0; let sw = image.naturalWidth; let sh = image.naturalHeight; if (sourceRatio > targetRatio) { sw = image.naturalHeight * targetRatio; sx = (image.naturalWidth - sw) / 2; } else { sh = image.naturalWidth / targetRatio; sy = 0; } if (zoom > 1) { const zoomedWidth = sw / zoom; const zoomedHeight = sh / zoom; sx += (sw - zoomedWidth) / 2; sy += (sh - zoomedHeight) / 2; sw = zoomedWidth; sh = zoomedHeight; } context.drawImage(image, sx, sy, sw, sh, x, y, width, height); } function slugify(text) { return String(text || "") .trim() .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .slice(0, 48); } function cityHeading(city) { return [city.cityZh, city.cityEn].filter(Boolean).join(" "); } function fullBookTitle(book, city) { if (book.fullTitleZh) return book.fullTitleZh; if (book.titleZh && book.subtitleZh) return `${book.titleZh}:${book.subtitleZh}`; return book.titleZh || city.cityZh || city.cityEn || ""; } function titleLayoutForBook(book, city) { const fallback = fullBookTitle(book, city); const title = book?.titleZh || ""; const subtitle = book?.subtitleZh || ""; if (fallback === "民族国家构建视角下的苏丹内战研究") { return { mode: "split", primary: "民族国家构建", secondary: "视角下的苏丹内战研究" }; } if (!hasChineseText(fallback)) { return { mode: "original", primary: fallback, secondary: "" }; } if (subtitle) { const total = titleVisualLength(fallback); const primary = titleVisualLength(title); if (total > 12.5 || primary > 9) { return { mode: "subtitle", primary: title, secondary: subtitle, longSubtitle: titleVisualLength(subtitle) >= 30 }; } } return { mode: "single", primary: fallback, secondary: "" }; } function renderBookTitle(element, layout) { element.classList.toggle("is-title-split", Boolean(layout.secondary)); element.classList.toggle("is-title-subtitle", layout.mode === "subtitle"); element.classList.toggle("is-title-equal-split", layout.mode === "split"); element.classList.toggle("is-title-original", layout.mode === "original"); element.classList.toggle("is-title-long-subtitle", Boolean(layout.longSubtitle)); const primary = document.createElement("span"); primary.className = "book-card__title-main"; primary.textContent = layout.primary; if (!layout.secondary) { element.replaceChildren(primary); return; } const secondary = document.createElement("span"); secondary.className = "book-card__title-sub"; secondary.textContent = layout.secondary; element.replaceChildren(primary, secondary); } function hasChineseText(text) { return /[\u4e00-\u9fff]/.test(String(text || "")); } function titleVisualLength(text) { return [...String(text || "")].reduce((total, character) => { if (/\s/.test(character)) return total; if (/[\u4e00-\u9fff]/.test(character)) return total + 1; if (/[A-Za-z0-9]/.test(character)) return total + 0.55; return total + 0.35; }, 0); } function bookMetaParts(book, options = {}) { if (options.fromDataset) { return { authors: book.authors?.[0] || "", details: book.publisher || "", }; } return { authors: book.authors?.length ? book.authors.join(" / ") : "", details: [book.publisher, book.year].filter(Boolean).join(" · "), }; } function renderBookMeta(element, book, options = {}) { const { authors, details } = bookMetaParts(book, options); element.dataset.authors = authors; element.dataset.details = details; element.textContent = ""; if (!options.split || !authors || !details) { element.textContent = [authors, details].filter(Boolean).join(" · "); return; } const authorLine = document.createElement("span"); authorLine.className = "book-card__meta-authors"; authorLine.textContent = authors; const detailLine = document.createElement("span"); detailLine.className = "book-card__meta-details"; detailLine.textContent = details; element.append(authorLine, detailLine); } function bookMeta(book) { const { authors, details } = bookMetaParts(book); return [authors, details].filter(Boolean).join(" · "); } function recommendationText(book) { if (book.oneLine) return book.oneLine; if (Array.isArray(book.recommendation) && book.recommendation.length) { return book.recommendation.join(" "); } return book.summary || book.sourceIntro || "这座城市的第一本入口书,等待补充更完整的推荐理由。"; } function shortBookText(book) { const explicitShortText = book.oneLine || ( Array.isArray(book.recommendation) && book.recommendation.length ? book.recommendation.join(" ") : "" ); return truncateText(explicitShortText, 76, { preserveLineBreaks: true }); } function detailBookText(book) { return normalizeDisplayText(book.summary || book.sourceIntro || recommendationText(book), { preserveLineBreaks: true }); } function detailBookParagraphs(book) { const text = detailBookText(book); if (!text) return []; const blankLineParagraphs = text.split(/\n{2,}/).map((paragraph) => paragraph.trim()).filter(Boolean); const paragraphs = blankLineParagraphs.length >= 2 ? blankLineParagraphs : text.split(/\n+/).map((paragraph) => paragraph.trim()).filter(Boolean); return paragraphs.slice(0, 3); } function appendTextWithMarkdownLinks(element, text) { const linkPattern = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g; let cursor = 0; let match = linkPattern.exec(text); while (match) { const [source, label, href] = match; if (match.index > cursor) { element.append(document.createTextNode(text.slice(cursor, match.index))); } let safeUrl = null; try { const url = new URL(href); if (url.protocol === "http:" || url.protocol === "https:") safeUrl = url.href; } catch { safeUrl = null; } if (safeUrl) { const link = document.createElement("a"); link.href = safeUrl; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = label; element.append(link); } else { element.append(document.createTextNode(source)); } cursor = match.index + source.length; match = linkPattern.exec(text); } if (cursor < text.length) { element.append(document.createTextNode(text.slice(cursor))); } } function renderDetailBookIntro(element, book) { const paragraphs = detailBookParagraphs(book); element.replaceChildren(); element.hidden = paragraphs.length === 0; clearDetailIntroFit(element); paragraphs.forEach((paragraphText) => { const paragraph = document.createElement("p"); appendTextWithMarkdownLinks(paragraph, paragraphText); element.append(paragraph); }); } function normalizeDisplayText(text, { preserveLineBreaks = false } = {}) { const raw = String(text || "").replace(/\r\n?/g, "\n"); if (!preserveLineBreaks) return raw.replace(/\s+/g, " ").trim(); return raw .split("\n") .map((line) => line.replace(/[ \t\f\v]+/g, " ").trim()) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function truncateText(text, maxLength, options = {}) { const clean = normalizeDisplayText(text, options); if (clean.length <= maxLength) return clean; return `${clean.slice(0, maxLength - 1)}…`; } function pointBooks(point) { if (!point) return []; const books = Array.isArray(point.books) && point.books.length ? point.books : [point]; if (point.specialContext) { return books.filter((book) => !book.isPlaceholder); } const selectedBooks = books.filter(bookIsSelected); if (point.searchContext) { return selectedBooks; } if (SPECIAL_CITY_IDS.has(point.cityId || point.id)) { return selectedBooks.slice(0, SPECIAL_CITY_GLOBAL_BOOK_LIMIT); } return selectedBooks; } function activeBookIndex(point) { if (!point) return 0; const books = pointBooks(point); const index = state.activeBookIndexes.get(point.id) || 0; return clampBookIndex(point, index); } function clampBookIndex(point, index) { const books = pointBooks(point); return clamp(Number.isFinite(index) ? index : 0, 0, Math.max(books.length - 1, 0)); } function activeBook(point) { const books = pointBooks(point); return books[activeBookIndex(point)] || null; } function captureDetailOrigin() { const point = state.activePoint; if (!point) return null; return { point, pointId: point.id, bookIndex: activeBookIndex(point), explored: false, }; } function restoreDetailOriginBookmark() { const origin = state.detailOrigin; const point = state.activePoint || origin?.point; if (!point) return false; if (!state.activePoint) { state.activePoint = point; state.activeBookIndexes.set(origin.pointId, origin.bookIndex); } state.bookCardMode = "ticket"; state.detailOrigin = null; syncCityMarkerActiveClasses(); syncTooltip(createProjection()); return true; } function returnToBookshelfFromDetail() { state.detailReturnTarget = null; closeTooltip(); openBookshelf(); } function shouldReturnToDetailOrigin() { const origin = state.detailOrigin; if (!origin || state.bookCardMode !== "detail") return false; if (pointUsesSpecialBookmarkLayer(state.activePoint) || pointUsesSpecialBookmarkLayer(origin.point)) return false; return origin.explored; } function handleTooltipClose() { if (state.detailReturnTarget === "bookshelf" && state.bookCardMode === "detail") { returnToBookshelfFromDetail(); return; } if (shouldReturnToDetailOrigin() && restoreDetailOriginBookmark()) return; closeTooltip(); } function cityExplorePoints() { return mapData.cities .filter((city) => pointBooks(city).length > 0 && Number.isFinite(cityCoordinates(city)[0])) .slice() .sort((a, b) => { const [aLon, aLat] = cityCoordinates(a); const [bLon, bLat] = cityCoordinates(b); return aLon - bLon || aLat - bLat || String(a.id).localeCompare(String(b.id)); }); } function activeCityExploreIndex(cities) { const activeId = state.activePoint?.id; const exactIndex = cities.findIndex((city) => city.id === activeId); if (exactIndex >= 0) return exactIndex; const activeLon = cityCoordinates(state.activePoint || {})[0]; if (!Number.isFinite(activeLon)) return 0; const nextIndex = cities.findIndex((city) => cityCoordinates(city)[0] >= activeLon); return nextIndex >= 0 ? nextIndex : 0; } function pageActiveCity(delta) { if (state.activePoint?.specialContext) return; const cities = cityExplorePoints(); if (!cities.length || state.bookCardMode !== "detail") return; if (!state.detailOrigin) state.detailOrigin = captureDetailOrigin(); const nextIndex = (activeCityExploreIndex(cities) + delta + cities.length) % cities.length; state.activePoint = cities[nextIndex]; trackEvent("city_paged", cityAnalyticsProps(state.activePoint, { source_view: "book_detail", direction: delta > 0 ? "next" : "previous", city_index: nextIndex, city_count: cities.length, })); if (state.detailOrigin) state.detailOrigin.explored = true; state.bookCardMode = "detail"; syncCityMarkerActiveClasses(); updateActiveDetailAtFixedAnchor(); playUiSound("pageFlick"); } function pageActiveBook(delta) { const point = state.activePoint; const books = pointBooks(point); if (!point || books.length <= 1) return; const nextIndex = (activeBookIndex(point) + delta + books.length) % books.length; state.activeBookIndexes.set(point.id, nextIndex); trackEvent("book_paged", bookAnalyticsProps(point, books[nextIndex], { source_view: state.bookCardMode === "detail" ? "book_detail" : "book_ticket", direction: delta > 0 ? "next" : "previous", book_index: nextIndex, book_count: books.length, })); if (point.specialContext) { trackEvent("city_special_book_paged", bookAnalyticsProps(point, books[nextIndex], { source_view: "city_special_detail", direction: delta > 0 ? "next" : "previous", book_index: nextIndex, book_count: books.length, special_id: point.specialContext.id || "", special_category: point.specialContext.category || "", })); } playUiSound("pageFlick"); if (state.bookCardMode === "detail") { if (!state.detailOrigin) state.detailOrigin = captureDetailOrigin(); if (state.detailOrigin) state.detailOrigin.explored = true; updateActiveDetailAtFixedAnchor(); } else { syncTooltip(createProjection()); } } function cityCoordinates(city) { const [lon, lat] = city.coordinates || [city.lon, city.lat]; return [Number(lon), Number(lat)]; } function cityIsCapital(city) { const roles = Array.isArray(city.cityRole) ? city.cityRole : [city.cityRole].filter(Boolean); return city.isCapital === true || roles.includes("national_capital"); } function cityMarkerScale(city) { return cityIsCapital(city) ? CAPITAL_MARKER_SCALE : 1; } function cityRadius(city) { return 3.43 * cityMarkerScale(city); } function cityHaloRadius(city) { return 5.17 * cityMarkerScale(city); } function cityWeightClass(city) { return Number(city.displayWeight || 0) >= 90 ? "city-point--major" : "city-point--standard"; } function pointLabel(city) { const books = pointBooks(city); const bookTitle = books[0]?.titleZh ? `,${books[0].titleZh}` : ""; const count = books.length > 1 ? `,共${books.length}本书` : ""; return `${city.cityZh || city.cityEn},${city.countryZh || city.countryEn}${bookTitle}${count}`; } function clampTooltipPoint(point) { const { width: cardWidth, height: cardHeight } = tooltipCardSize(); const bounds = tooltipBounds(cardWidth, cardHeight); return { x: clamp(point.x, bounds.minX, bounds.maxX), y: clamp(point.y, bounds.minY, bounds.maxY), }; } function updateActiveDetailAtFixedAnchor() { if (!state.activePoint) return; updateTooltipContent(state.activePoint); elements.cityTooltip.hidden = false; placeDetailTooltip(); requestAnimationFrame(() => { placeDetailTooltip(); requestAnimationFrame(() => placeDetailTooltip()); }); document.fonts?.ready.then(() => placeDetailTooltip()); } function syncCityMarkerActiveClasses() { cityMarkerCache.forEach((markerEntry, cityId) => { markerEntry.element.classList.toggle("is-active", cityId === state.activePoint?.id); }); } function tooltipCardSize() { return { width: elements.cityTooltip.offsetWidth || 456, height: elements.cityTooltip.offsetHeight || 218, }; } function placeTooltipAtAnchor(anchor, city, options = {}) { const wasHidden = elements.cityTooltip.hidden; const previousVisibility = elements.cityTooltip.style.visibility; if (wasHidden) { elements.cityTooltip.style.visibility = "hidden"; elements.cityTooltip.hidden = false; } const anchoredPoint = resolveTooltipPoint(anchor, city, options); state.tooltipPoint = anchoredPoint; elements.cityTooltip.style.left = `${anchoredPoint.x}px`; elements.cityTooltip.style.top = `${anchoredPoint.y}px`; syncTooltipSafeSize(); if (wasHidden) elements.cityTooltip.style.visibility = previousVisibility; elements.cityTooltip.hidden = false; } function placeDetailTooltip() { const wasHidden = elements.cityTooltip.hidden; const previousVisibility = elements.cityTooltip.style.visibility; if (wasHidden) { elements.cityTooltip.style.visibility = "hidden"; elements.cityTooltip.hidden = false; } const { width: cardWidth, height: cardHeight } = tooltipCardSize(); const stageRect = elements.mapStage.getBoundingClientRect(); const bounds = detailTooltipBounds(cardWidth, cardHeight); const copyColumnOffset = detailCopyColumnOffset(); const copyColumnWidth = detailCopyColumnWidth(); const copyAnchorX = detailCopyAnchorX(stageRect, copyColumnOffset, copyColumnWidth, bounds); const point = { x: clamp(copyAnchorX - copyColumnOffset - copyColumnWidth / 2, bounds.minX, bounds.maxX), y: clamp((stageRect.height - cardHeight) / 2, bounds.minY, bounds.maxY), }; state.tooltipPoint = point; elements.cityTooltip.style.left = `${point.x}px`; elements.cityTooltip.style.top = `${point.y}px`; syncTooltipSafeSize(); if (wasHidden) elements.cityTooltip.style.visibility = previousVisibility; elements.cityTooltip.hidden = false; } function detailTooltipBounds(cardWidth, cardHeight) { const stageRect = elements.mapStage.getBoundingClientRect(); const rightEdge = stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.right; const leftEdge = detailTooltipLeftSafeEdge(stageRect, cardWidth, rightEdge); const topEdge = tooltipTopSafeEdge(stageRect); const bottomEdge = tooltipBottomSafeEdge(stageRect); const safeWidth = detailTooltipSafeWidth(stageRect, cardWidth, rightEdge); elements.cityTooltip.style.setProperty("--tooltip-safe-width", `${Math.floor(safeWidth)}px`); return { minX: leftEdge, maxX: Math.max(rightEdge - cardWidth, leftEdge), minY: topEdge, maxY: Math.max(bottomEdge - cardHeight, topEdge), }; } function detailTooltipLeftSafeEdge(stageRect, cardWidth, rightEdge) { const fallbackLeft = GLOBAL_TOOLTIP_SAFE_ZONE.inset; const rightShiftedLeft = rightEdge - cardWidth; const heroSafeLeft = detailHeroSafeLeft(stageRect, cardWidth, fallbackLeft); return Math.max(rightShiftedLeft, heroSafeLeft, fallbackLeft); } function detailHeroSafeLeft(stageRect, cardWidth, fallbackLeft) { if (!elements.heroCopy || elements.heroCopy.hidden) return fallbackLeft; const heroRect = elements.heroCopy.getBoundingClientRect(); if (!heroRect.width || heroRect.right <= stageRect.left || heroRect.left >= stageRect.right) return fallbackLeft; const allowedOverlap = detailAllowsHeroOverlap(cardWidth) ? DETAIL_WIDE_HERO_OVERLAP : 0; return Math.max(fallbackLeft, heroRect.right - stageRect.left + DETAIL_HERO_CLEARANCE - allowedOverlap); } function detailAllowsHeroOverlap(cardWidth) { return cardWidth >= DETAIL_WIDE_CARD_THRESHOLD || Boolean(elements.cityTooltip.querySelector(".book-card__detail-view.is-wide-detail-cover")); } function detailTooltipSafeWidth(stageRect, cardWidth, rightEdge) { const contentLeft = detailHeroSafeLeft(stageRect, cardWidth, GLOBAL_TOOLTIP_SAFE_ZONE.inset); return Math.max(rightEdge - contentLeft, 220); } function detailCopyColumnOffset() { const cardRect = elements.cityTooltip.getBoundingClientRect(); const copyRect = elements.cityTooltip.querySelector(".book-card__detail-body")?.getBoundingClientRect(); if (!cardRect.width || !copyRect?.width) return 0; return Math.max(0, copyRect.left - cardRect.left); } function detailCopyColumnWidth() { const copyRect = elements.cityTooltip.querySelector(".book-card__detail-body")?.getBoundingClientRect(); return copyRect?.width || 360; } function detailCopyAnchorX(stageRect, copyColumnOffset, copyColumnWidth, bounds) { const desiredAnchor = stageRect.width * DETAIL_COPY_ANCHOR_RATIO; const minAnchor = bounds.minX + copyColumnOffset + copyColumnWidth / 2; const maxAnchor = bounds.maxX + copyColumnOffset + copyColumnWidth / 2; return clamp(desiredAnchor, minAnchor, Math.max(maxAnchor, minAnchor)); } function resolveTooltipPoint(anchor, city, options = {}) { const { width: cardWidth, height: cardHeight } = tooltipCardSize(); const bounds = tooltipBounds(cardWidth, cardHeight); const markerBoxes = options.avoidMarker ? activeMarkerAvoidanceBoxes(city) : []; const avoidBoxes = markerBoxes; const candidates = tooltipPositionCandidates(anchor, cardWidth, cardHeight, bounds, { markerBoxes, }); return candidates .map((candidate) => { const x = clamp(candidate.x, bounds.minX, bounds.maxX); const y = clamp(candidate.y, bounds.minY, bounds.maxY); const box = { left: x, right: x + cardWidth, top: y, bottom: y + cardHeight, }; const outOfBounds = Math.max(0, bounds.minX - candidate.x) + Math.max(0, candidate.x - bounds.maxX) + Math.max(0, bounds.minY - candidate.y) + Math.max(0, candidate.y - bounds.maxY); const overlapPenalty = avoidBoxes.reduce( (score, avoidBox) => score + overlapArea(box, avoidBox) * (avoidBox.weight || 80), 0, ); const centerDistance = Math.hypot(x + cardWidth / 2 - anchor.x, y + cardHeight / 2 - anchor.y); return { x, y, score: candidate.priority + overlapPenalty + outOfBounds * 260 + Math.max(0, centerDistance - 120) * 2.4, }; }) .sort((a, b) => a.score - b.score)[0] || clampTooltipPoint(anchor); } function tooltipPositionCandidates(anchor, cardWidth, cardHeight, bounds, context = {}) { const gap = 2; const verticalBase = anchor.y - cardHeight * TOOLTIP_CARD_ANCHOR_RATIO; const verticalOffsets = [0, -28, 28, -56, 56, -92, 92]; const markerRight = context.markerBoxes?.length ? Math.max(...context.markerBoxes.map((box) => box.right)) : null; const markerLeft = context.markerBoxes?.length ? Math.min(...context.markerBoxes.map((box) => box.left)) : null; const rightClearX = Math.max( anchor.x + gap, markerRight == null ? bounds.minX : markerRight + 2, ); const leftClearX = Math.min( anchor.x - cardWidth - gap, markerLeft == null ? bounds.maxX : markerLeft - cardWidth - 2, ); const horizontalCandidates = [ { key: "right-clear", x: rightClearX, priority: 0 }, { key: "right-near", x: anchor.x + gap, priority: 120 }, { key: "left-clear", x: leftClearX, priority: 2600 }, { key: "left-near", x: anchor.x - cardWidth - gap, priority: 3400 }, ]; return horizontalCandidates.flatMap((horizontal) => verticalOffsets.map((offset, index) => ({ key: `${horizontal.key}-${index}`, x: horizontal.x, y: verticalBase + offset, priority: horizontal.priority + index * 80 + Math.abs(offset) * 2, })), ); } function activeMarkerAvoidanceBoxes(city) { const markerEntry = cityMarkerCache.get(city?.id); if (!markerEntry) return []; const boxes = []; const labelBox = cityLabelTextStageBox(markerEntry.label, 6); if (labelBox) boxes.push({ ...labelBox, weight: 160 }); const haloBox = elementStageBox(markerEntry.halo || markerEntry.pointElement || markerEntry.element, 4); if (haloBox) boxes.push({ ...haloBox, weight: 120 }); return boxes; } function heroAvoidanceBoxes() { if (state.activeSpecialId || !elements.heroCopy || elements.heroCopy.hidden) return []; const box = elementStageBox(elements.heroCopy, 16); return box ? [{ ...box, weight: 100 }] : []; } function elementStageBox(element, padding = 0) { if (!element) return null; const rect = element.getBoundingClientRect(); if (!rect.width || !rect.height) return null; const stageRect = elements.mapStage.getBoundingClientRect(); return { left: rect.left - stageRect.left - padding, right: rect.right - stageRect.left + padding, top: rect.top - stageRect.top - padding, bottom: rect.bottom - stageRect.top + padding, }; } function cityLabelTextStageBox(label, padding = 0) { if (!label) return null; const stageRect = elements.mapStage.getBoundingClientRect(); const textRects = [...label.querySelectorAll(".city-html-marker__zh, .city-html-marker__en")] .filter((element) => !element.hidden) .map((element) => { const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null; const rect = element.getBoundingClientRect(); if (!rect.width || !rect.height) return null; return rect; }) .filter(Boolean); if (!textRects.length) return elementStageBox(label, padding); return { left: Math.min(...textRects.map((rect) => rect.left)) - stageRect.left - padding, right: Math.max(...textRects.map((rect) => rect.right)) - stageRect.left + padding, top: Math.min(...textRects.map((rect) => rect.top)) - stageRect.top - padding, bottom: Math.max(...textRects.map((rect) => rect.bottom)) - stageRect.top + padding, }; } function tooltipBounds(cardWidth, cardHeight) { const stageRect = elements.mapStage.getBoundingClientRect(); const leftEdge = tooltipLeftSafeEdge(stageRect); const rightEdge = tooltipRightSafeEdge(stageRect); const topEdge = tooltipTopSafeEdge(stageRect); const bottomEdge = tooltipBottomSafeEdge(stageRect); const safeWidth = Math.max(rightEdge - leftEdge, 220); elements.cityTooltip.style.setProperty("--tooltip-safe-width", `${Math.floor(safeWidth)}px`); return { minX: leftEdge, maxX: Math.max(rightEdge - cardWidth, leftEdge), minY: topEdge, maxY: Math.max(bottomEdge - cardHeight, topEdge), }; } function tooltipLeftSafeEdge(stageRect) { if (state.activeSpecialId || !elements.heroCopy || elements.heroCopy.hidden) { return GLOBAL_TOOLTIP_SAFE_ZONE.inset; } const heroRect = elements.heroCopy.getBoundingClientRect(); if (!heroRect.width || heroRect.right <= stageRect.left || heroRect.left >= stageRect.right) { return GLOBAL_TOOLTIP_SAFE_ZONE.inset; } const heroRight = heroRect.right - stageRect.left + GLOBAL_TOOLTIP_SAFE_ZONE.heroRightPadding; const desiredLeft = Math.max( GLOBAL_TOOLTIP_SAFE_ZONE.inset, Math.min(heroRight, Math.max(GLOBAL_TOOLTIP_SAFE_ZONE.minLeft, stageRect.width * GLOBAL_TOOLTIP_SAFE_ZONE.leftRatio)), ); return clamp(desiredLeft, GLOBAL_TOOLTIP_SAFE_ZONE.inset, stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.inset); } function tooltipTopSafeEdge(stageRect) { const kickerRect = elements.heroKicker?.getBoundingClientRect(); if (!kickerRect?.height) return GLOBAL_TOOLTIP_SAFE_ZONE.top; return clamp(kickerRect.top - stageRect.top, GLOBAL_TOOLTIP_SAFE_ZONE.inset, GLOBAL_TOOLTIP_SAFE_ZONE.top); } function tooltipRightSafeEdge(stageRect) { const fallbackEdge = stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.right; const controlEdges = [elements.specialBackButton, elements.themeToggle] .map((element) => elementStageRightEdge(element, stageRect)) .filter((edge) => edge != null); const rightCapsuleEdges = [...elements.specialLabelLayer.querySelectorAll(".special-capsule")] .map((element) => elementStageRightEdge(element, stageRect)) .filter((edge) => edge != null && edge > stageRect.width * 0.55); const visibleEdges = rightCapsuleEdges.length ? rightCapsuleEdges : controlEdges; if (!visibleEdges.length) return fallbackEdge; return Math.min(fallbackEdge, Math.max(...visibleEdges)); } function tooltipBottomSafeEdge(stageRect) { const fallbackEdge = stageRect.height - GLOBAL_TOOLTIP_SAFE_ZONE.bottom; const footerEdges = [elements.musicSignature] .map((element) => elementStageTopEdge(element, stageRect)) .filter((edge) => edge != null); if (!footerEdges.length) return fallbackEdge; return Math.min(fallbackEdge, Math.min(...footerEdges) - GLOBAL_TOOLTIP_SAFE_ZONE.footerTopPadding); } function syncTooltipSafeSize() { const stageRect = elements.mapStage.getBoundingClientRect(); const rightEdge = state.bookCardMode === "detail" ? stageRect.width - GLOBAL_TOOLTIP_SAFE_ZONE.right : tooltipRightSafeEdge(stageRect); const { width: cardWidth } = tooltipCardSize(); const leftEdge = state.bookCardMode === "detail" ? detailTooltipLeftSafeEdge(stageRect, cardWidth, rightEdge) : tooltipLeftSafeEdge(stageRect); const safeWidth = state.bookCardMode === "detail" ? detailTooltipSafeWidth(stageRect, cardWidth, rightEdge) : Math.max(rightEdge - leftEdge, 220); elements.cityTooltip.style.setProperty("--tooltip-safe-width", `${Math.floor(safeWidth)}px`); } function elementStageRightEdge(element, stageRect) { if (!element || element.hidden) return null; const rect = element.getBoundingClientRect(); if (!rect.width || !rect.height || rect.right <= stageRect.left || rect.left >= stageRect.right) return null; const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null; return rect.right - stageRect.left; } function elementStageTopEdge(element, stageRect) { if (!element || element.hidden) return null; const rect = element.getBoundingClientRect(); if (!rect.width || !rect.height || rect.bottom <= stageRect.top || rect.top >= stageRect.bottom) return null; const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return null; return rect.top - stageRect.top; } function svgToStageFreePoint(x, y) { const point = elements.mapSvg.createSVGPoint(); point.x = x; point.y = y; const transformed = point.matrixTransform(elements.mapSvg.getScreenCTM()); const stageRect = elements.mapStage.getBoundingClientRect(); return { x: transformed.x - stageRect.left, y: transformed.y - stageRect.top, }; } function projectionToStageGeometry(projection) { const center = svgToStageFreePoint(projection.cx, projection.cy); const radiusPoint = svgToStageFreePoint(projection.cx + projection.radius, projection.cy); return { ...projection, cx: center.x, cy: center.y, radius: Math.hypot(radiusPoint.x - center.x, radiusPoint.y - center.y), }; } function stageToSvgFreePoint(x, y) { const point = elements.mapSvg.createSVGPoint(); const stageRect = elements.mapStage.getBoundingClientRect(); point.x = stageRect.left + x; point.y = stageRect.top + y; const matrix = elements.mapSvg.getScreenCTM(); if (!matrix) { return { x: (x / Math.max(stageRect.width, 1)) * VIEWBOX.width, y: (y / Math.max(stageRect.height, 1)) * VIEWBOX.height, }; } const transformed = point.matrixTransform(matrix.inverse()); return { x: transformed.x, y: transformed.y, }; } function tooltipFocusCenterForCoordinate(coordinate, zoom) { const [lon, lat] = coordinate; const stageRect = elements.mapStage.getBoundingClientRect(); if (!stageRect.width || !stageRect.height) return [lon, lat]; const tooltipWidth = Math.max(tooltipCardSize().width || 0, stageRect.width < 760 ? 260 : 360); const leftEdge = tooltipLeftSafeEdge(stageRect); const rightEdge = tooltipRightSafeEdge(stageRect); const markerReserve = stageRect.width < 760 ? 86 : 146; const minAnchorX = Math.min(leftEdge + 72, stageRect.width - 72); const maxAnchorX = Math.max(minAnchorX, rightEdge - tooltipWidth - markerReserve); const desiredStageX = clamp( stageRect.width * (stageRect.width < 760 ? 0.5 : 0.62), minAnchorX, maxAnchorX, ); const desiredSvgPoint = stageToSvgFreePoint(desiredStageX, stageRect.height * 0.56); const radius = BASE_RADIUS * zoom; const cosLat = Math.cos(toRadians(lat)); if (!Number.isFinite(desiredSvgPoint.x) || !radius || Math.abs(cosLat) < 0.001) return [lon, lat]; const projectionCenterX = VIEWBOX.width * 0.84; const desiredOffset = (desiredSvgPoint.x - projectionCenterX) / radius; const delta = Math.asin(clamp(desiredOffset / cosLat, -0.92, 0.92)); return [normalizeLongitude(lon - toDegrees(delta)), lat]; } function createProjection() { const specialView = Boolean(state.activeSpecialId); const projection = { centerLon: state.center[0], centerLat: state.center[1], centerLonRad: toRadians(state.center[0]), centerLatRad: toRadians(state.center[1]), radius: BASE_RADIUS * state.zoom, cx: VIEWBOX.width * (specialView ? 0.5 : 0.84), cy: VIEWBOX.height * (specialView ? 0.5 : 0.56), }; attachGeoPathProjection(projection); return projection; } function attachGeoPathProjection(projection) { const d3Geo = globalThis.d3; if (!d3Geo?.geoOrthographic || !d3Geo?.geoPath) return; const globeProjection = d3Geo.geoOrthographic() .translate([projection.cx, projection.cy]) .scale(projection.radius) .rotate([-projection.centerLon, -projection.centerLat]) .clipAngle(90) .precision(0.6); projection.geoProjection = globeProjection; projection.geoPath = d3Geo.geoPath(globeProjection); } function animateView({ center, zoom, onComplete }) { if (state.viewAnimationFrame) cancelAnimationFrame(state.viewAnimationFrame); const startCenter = [...state.center]; const startZoom = state.zoom; const targetCenter = [normalizeLongitude(center[0]), clamp(center[1], -72, 72)]; const startAt = performance.now(); const duration = 620; const tick = (timestamp) => { const progress = clamp((timestamp - startAt) / duration, 0, 1); const eased = easeOutCubic(progress); state.center = [ interpolateLongitude(startCenter[0], targetCenter[0], eased), startCenter[1] + (targetCenter[1] - startCenter[1]) * eased, ]; state.zoom = startZoom + (zoom - startZoom) * eased; render({ skipTooltipContent: true, freezeCityLabelLayout: true }); if (progress < 1) { state.viewAnimationFrame = requestAnimationFrame(tick); } else { state.viewAnimationFrame = null; render({ freezeCityLabelLayout: true }); onComplete?.(); } }; state.viewAnimationFrame = requestAnimationFrame(tick); } function interpolateLongitude(start, end, progress) { let delta = end - start; if (delta > 180) delta -= 360; if (delta < -180) delta += 360; return normalizeLongitude(start + delta * progress); } function easeOutCubic(value) { return 1 - Math.pow(1 - value, 3); } function projectPoint(point, projection) { const [lon, lat] = point; const lambda = toRadians(lon); const phi = toRadians(lat); const delta = lambda - projection.centerLonRad; const cosPhi = Math.cos(phi); const sinPhi = Math.sin(phi); const cosCenter = Math.cos(projection.centerLatRad); const sinCenter = Math.sin(projection.centerLatRad); const visible = sinCenter * sinPhi + cosCenter * cosPhi * Math.cos(delta) >= -0.015; return [ projection.cx + projection.radius * cosPhi * Math.sin(delta), projection.cy - projection.radius * (cosCenter * sinPhi - sinCenter * cosPhi * Math.cos(delta)), visible, ]; } function featureToPath(feature, projection) { const geometry = feature.geometry; if (geometry.type === "LineString" || geometry.type === "MultiLineString") { const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.coordinates; return lines.map((line) => lineToPath(line, projection)).filter(Boolean).join(" "); } const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates; return polygons.map((polygon) => { return polygon.map((ring) => lineToPath(ring, projection)).filter(Boolean).join(" "); }).filter(Boolean).join(" "); } function featureEntryToPath(worldFeature, projection) { const manualPath = featureToPathWithVisibility(worldFeature.feature, projection); if (!manualPath.visiblePoints) { return ""; } if (!projection.geoPath || manualPath.visiblePoints >= manualPath.totalPoints) return manualPath.path; const cacheKey = globePathCacheKey(worldFeature, projection); const cachedPath = state.globePathCache.get(cacheKey); if (cachedPath !== undefined) { state.globePathCache.delete(cacheKey); state.globePathCache.set(cacheKey, cachedPath); return cachedPath; } const path = projection.geoPath(worldFeature.feature) || ""; cacheGlobePath(cacheKey, path); return path; } function globePathCacheKey(worldFeature, projection) { return [ worldFeature.pathKey || featureIsoA3(worldFeature.feature) || "feature", quantizePathCacheValue(projection.centerLon, GLOBE_PATH_CACHE_CENTER_STEP), quantizePathCacheValue(projection.centerLat, GLOBE_PATH_CACHE_CENTER_STEP), quantizePathCacheValue(projection.radius, GLOBE_PATH_CACHE_RADIUS_STEP), quantizePathCacheValue(projection.cx, GLOBE_PATH_CACHE_RADIUS_STEP), quantizePathCacheValue(projection.cy, GLOBE_PATH_CACHE_RADIUS_STEP), ].join(":"); } function quantizePathCacheValue(value, step) { return Math.round(value / step) * step; } function cacheGlobePath(cacheKey, path) { state.globePathCache.set(cacheKey, path); while (state.globePathCache.size > GLOBE_PATH_CACHE_LIMIT) { const firstKey = state.globePathCache.keys().next().value; state.globePathCache.delete(firstKey); } } function featureToPathWithVisibility(feature, projection) { const geometry = feature.geometry; const result = { path: "", visiblePoints: 0, totalPoints: 0, }; if (geometry.type === "LineString" || geometry.type === "MultiLineString") { const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.coordinates; result.path = lines.map((line) => { const lineResult = lineToPathWithVisibility(line, projection); result.visiblePoints += lineResult.visiblePoints; result.totalPoints += lineResult.totalPoints; return lineResult.path; }).filter(Boolean).join(" "); return result; } const polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.coordinates; result.path = polygons.map((polygon) => { return polygon.map((ring) => { const ringResult = lineToPathWithVisibility(ring, projection); result.visiblePoints += ringResult.visiblePoints; result.totalPoints += ringResult.totalPoints; return ringResult.path; }).filter(Boolean).join(" "); }).filter(Boolean).join(" "); return result; } function lineToPathWithVisibility(line, projection) { let path = ""; let drawing = false; let visiblePoints = 0; for (const point of line) { const [x, y, visible] = projectPoint(point, projection); if (!visible) { drawing = false; } else { path += `${drawing ? "L" : "M"} ${x.toFixed(2)} ${y.toFixed(2)} `; drawing = true; visiblePoints += 1; } } return { path: path.trim(), visiblePoints, totalPoints: line.length, }; } function lineToPath(line, projection) { let path = ""; let drawing = false; for (const point of line) { const [x, y, visible] = projectPoint(point, projection); if (!visible) { drawing = false; continue; } path += `${drawing ? "L" : "M"} ${x.toFixed(2)} ${y.toFixed(2)} `; drawing = true; } return path.trim(); } function featureIsVisible(worldFeature, projection) { if (projectPoint(worldFeature.centroid, projection)[2]) return true; return worldFeature.flatCoordinates.some((point) => projectPoint(point, projection)[2]); } function calculateCentroid(points) { const total = points.reduce((memo, point) => [memo[0] + point[0], memo[1] + point[1]], [0, 0]); return [total[0] / points.length, total[1] / points.length]; } function graticulePaths(projection) { const paths = []; for (let lon = -180; lon <= 180; lon += 20) { const points = []; for (let lat = -80; lat <= 80; lat += 3) points.push([lon, lat]); paths.push(pointsToProjectedPath(points, projection)); } for (let lat = -60; lat <= 80; lat += 20) { const points = []; for (let lon = -180; lon <= 180; lon += 3) points.push([lon, lat]); paths.push(pointsToProjectedPath(points, projection)); } return paths.filter(Boolean); } function pointsToProjectedPath(points, projection) { let path = ""; let drawing = false; for (const point of points) { const [x, y, visible] = projectPoint(point, projection); if (!visible) { drawing = false; continue; } path += `${drawing ? "L" : "M"} ${x.toFixed(2)} ${y.toFixed(2)} `; drawing = true; } return path.trim(); } function flattenCoordinates(coordinates) { if (typeof coordinates[0] === "number") return [coordinates]; return coordinates.flatMap(flattenCoordinates); } function createSvg(tag, attributes = {}, text = "") { const element = document.createElementNS("http://www.w3.org/2000/svg", tag); for (const [key, value] of Object.entries(attributes)) { element.setAttribute(key, String(value)); } if (text) element.textContent = text; return element; } function closeTooltip() { state.activePoint = null; state.tooltipPoint = null; state.bookCardMode = "ticket"; state.detailOrigin = null; state.detailReturnTarget = null; applyBookCardModeClass(); elements.cityTooltip.hidden = true; closeSharePreview(); render(); } function endDrag(event) { const drag = state.dragging; flushMapDragRender(drag); if (drag?.moved) { trackEvent("map_dragged", { source_view: "global_map", zoom: Number(state.zoom.toFixed(3)), center_lon: Number(state.center[0].toFixed(3)), center_lat: Number(state.center[1].toFixed(3)), }); } if (drag && !drag.moved && !drag.startedOnCityPoint && !event.target.closest(".city-html-marker, .special-capsule")) { closeTooltip(); } state.dragging = null; elements.mapSvg.classList.remove("is-dragging"); if (drag?.moved) render({ skipTooltipContent: true, freezeCityLabelLayout: true }); } function scheduleMapDragRender() { const drag = state.dragging; if (!drag || drag.renderFrame) return; drag.renderFrame = requestAnimationFrame(() => { drag.renderFrame = null; if (state.dragging !== drag) return; render({ skipTooltipContent: true, freezeCityLabelLayout: true }); }); } function flushMapDragRender(drag) { if (!drag?.renderFrame) return; cancelAnimationFrame(drag.renderFrame); drag.renderFrame = null; if (drag.moved) render({ skipTooltipContent: true, freezeCityLabelLayout: true }); } function startSubtleDrift() { const tick = (timestamp) => { if (!state.lastDriftAt) state.lastDriftAt = timestamp; const delta = timestamp - state.lastDriftAt; if (shouldPauseGlobeDrift()) { state.lastDriftAt = timestamp; } else if (!state.dragging && !state.hoverPoint && !state.activeSpecialId && !state.viewAnimationFrame && delta >= DRIFT_FRAME_MS) { state.center = [normalizeLongitude(state.center[0] - Math.min(delta, 48) * DRIFT_DEGREES_PER_MS), state.center[1]]; state.lastDriftAt = timestamp; render({ skipTooltipContent: true, freezeCityLabelLayout: true }); } else if (state.hoverPoint) { state.lastDriftAt = timestamp; syncGlobeTextures(createProjection()); } state.driftFrame = requestAnimationFrame(tick); }; state.driftFrame = requestAnimationFrame(tick); } function shouldPauseGlobeDrift() { return Boolean(state.activePoint && elements.cityTooltip && !elements.cityTooltip.hidden); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function normalizeLongitude(lon) { let normalized = lon; while (normalized > 180) normalized -= 360; while (normalized < -180) normalized += 360; return normalized; } function toRadians(degrees) { return degrees * Math.PI / 180; } function toDegrees(radians) { return radians * 180 / Math.PI; } applyTheme(); init();