Spaces:
Running
Running
| 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(); | |