From 7310555c1a52c68112618cbc644f6b3b58deed6f Mon Sep 17 00:00:00 2001 From: ChrAlpha <53332481+ChrAlpha@users.noreply.github.com> Date: Mon, 7 Jul 2025 17:22:56 +0800 Subject: [PATCH] feat: map explore page (#29) Signed-off-by: Innei Co-authored-by: MaxtuneLee Co-authored-by: Innei --- .gitignore | 1 + ...ssue-with-photo-viewer-closing-behavior.md | 44 + ...-triangle-display-in-maplibre-component.md | 96 + apps/web/package.json | 4 + .../src/components/ui/button/GlassButton.tsx | 47 + .../src/components/ui/hover-card/index.tsx | 53 + .../src/components/ui/lazy-image/index.tsx | 91 + apps/web/src/components/ui/map/1.png | Bin 0 -> 27890 bytes .../components/ui/map/ClusterPhotoGrid.tsx | 166 + apps/web/src/components/ui/map/GenericMap.tsx | 69 + .../src/components/ui/map/MapBackButton.tsx | 26 + .../src/components/ui/map/MapInfoPanel.tsx | 160 + apps/web/src/components/ui/map/MapLibre.tsx | 248 ++ .../src/components/ui/map/MapLibreStyle.json | 2837 +++++++++++++++++ .../src/components/ui/map/MapLoadingState.tsx | 42 + apps/web/src/components/ui/map/index.ts | 5 + .../ui/map/shared/ClusterMarker.tsx | 132 + .../components/ui/map/shared/GeoJsonLayer.tsx | 23 + .../components/ui/map/shared/MapControls.tsx | 113 + .../ui/map/shared/PhotoMarkerPin.tsx | 286 ++ .../components/ui/map/shared/clustering.ts | 91 + .../web/src/components/ui/map/shared/index.ts | 6 + .../web/src/components/ui/map/shared/types.ts | 71 + apps/web/src/lib/map-utils.ts | 206 ++ apps/web/src/modules/gallery/ActionGroup.tsx | 13 + apps/web/src/modules/map/MapLibreAdapter.tsx | 120 + apps/web/src/modules/map/MapProvider.tsx | 152 + apps/web/src/modules/map/MapSection.tsx | 118 + apps/web/src/pages/(main)/layout.tsx | 39 +- apps/web/src/pages/explory/index.tsx | 96 + apps/web/src/pages/layout.tsx | 23 + apps/web/src/styles/index.css | 6 - apps/web/src/types/map/index.ts | 62 + apps/web/src/types/map/provider.ts | 78 + config.example.json | 5 +- locales/app/en.json | 11 + locales/app/jp.json | 11 + locales/app/ko.json | 11 + locales/app/zh-CN.json | 11 + locales/app/zh-HK.json | 11 + locales/app/zh-TW.json | 11 + pnpm-lock.yaml | 830 ++++- site.config.ts | 8 + 43 files changed, 6327 insertions(+), 106 deletions(-) create mode 100644 .specstory/history/2025-07-06_11-08Z-issue-with-photo-viewer-closing-behavior.md create mode 100644 .specstory/history/2025-07-07_04-30Z-troubleshooting-triangle-display-in-maplibre-component.md create mode 100644 apps/web/src/components/ui/button/GlassButton.tsx create mode 100644 apps/web/src/components/ui/hover-card/index.tsx create mode 100644 apps/web/src/components/ui/lazy-image/index.tsx create mode 100644 apps/web/src/components/ui/map/1.png create mode 100644 apps/web/src/components/ui/map/ClusterPhotoGrid.tsx create mode 100644 apps/web/src/components/ui/map/GenericMap.tsx create mode 100644 apps/web/src/components/ui/map/MapBackButton.tsx create mode 100644 apps/web/src/components/ui/map/MapInfoPanel.tsx create mode 100644 apps/web/src/components/ui/map/MapLibre.tsx create mode 100644 apps/web/src/components/ui/map/MapLibreStyle.json create mode 100644 apps/web/src/components/ui/map/MapLoadingState.tsx create mode 100644 apps/web/src/components/ui/map/index.ts create mode 100644 apps/web/src/components/ui/map/shared/ClusterMarker.tsx create mode 100644 apps/web/src/components/ui/map/shared/GeoJsonLayer.tsx create mode 100644 apps/web/src/components/ui/map/shared/MapControls.tsx create mode 100644 apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx create mode 100644 apps/web/src/components/ui/map/shared/clustering.ts create mode 100644 apps/web/src/components/ui/map/shared/index.ts create mode 100644 apps/web/src/components/ui/map/shared/types.ts create mode 100644 apps/web/src/lib/map-utils.ts create mode 100644 apps/web/src/modules/map/MapLibreAdapter.tsx create mode 100644 apps/web/src/modules/map/MapProvider.tsx create mode 100644 apps/web/src/modules/map/MapSection.tsx create mode 100644 apps/web/src/pages/explory/index.tsx create mode 100644 apps/web/src/pages/layout.tsx create mode 100644 apps/web/src/types/map/index.ts create mode 100644 apps/web/src/types/map/provider.ts diff --git a/.gitignore b/.gitignore index b90e6a9e..889c270d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ apps/web/assets-git apps/web/public/thumbnails apps/web/src/data/photos-manifest.json .vercel +apps/ssr/.next diff --git a/.specstory/history/2025-07-06_11-08Z-issue-with-photo-viewer-closing-behavior.md b/.specstory/history/2025-07-06_11-08Z-issue-with-photo-viewer-closing-behavior.md new file mode 100644 index 00000000..43e3d0e4 --- /dev/null +++ b/.specstory/history/2025-07-06_11-08Z-issue-with-photo-viewer-closing-behavior.md @@ -0,0 +1,44 @@ + + +# Issue with photo viewer closing behavior (2025-07-06 11:08Z) + +_**User**_ + +为什么关闭图片浏览器的时候会自动回到网站首页,这里似乎没有相关的逻辑存在 + +--- + +_**Assistant**_ + +我来分析一下你的问题。从你提供的 `usePhotoViewer.ts` 代码中,我确实没有看到任何会导致关闭图片浏览器时自动回到网站首页的逻辑。`closeViewer` 函数只是简单地关闭了查看器并恢复了背景滚动。 + +让我搜索一下相关的代码,看看是否有其他地方可能导致这个行为: 现在让我搜索一下 `useSyncStateToUrl` 相关的代码,这看起来是关键的地方: 让我读取这个 layout 文件的完整内容: 找到问题了!在 `useSyncStateToUrl` 函数中,有一个 `useEffect` 监听了 `isOpen` 状态的变化。当图片浏览器关闭时(`isOpen` 为 `false`),代码会检查当前路径是否为 `/explory`,如果不是,就会在 500ms 后自动导航到首页 `/`。 + +具体的问题代码在第 106-116 行: + +```tsx +useEffect(() => { + if (!isRestored) return + + if (!isOpen) { + const isExploryPath = location.pathname === '/explory' + if (!isExploryPath) { + const timer = setTimeout(() => { + navigate('/') // 这里会导航到首页 + }, 500) + return () => clearTimeout(timer) + } + } else { + // ... 其他逻辑 + } +}, [currentIndex, isOpen, location.pathname, navigate]) +``` + +这个逻辑的设计意图是: +1. 当图片浏览器打开时(`isOpen` 为 `true`),根据当前选中的图片更新 URL 路径 +2. 当图片浏览器关闭时(`isOpen` 为 `false`),如果当前不在 `/explory` 路径,就延迟 500ms 后自动跳转到首页 + +所以关闭图片浏览器时自动回到首页的逻辑就在这里。如果你不希望这个行为,可以修改或移除这部分代码。 + +--- + diff --git a/.specstory/history/2025-07-07_04-30Z-troubleshooting-triangle-display-in-maplibre-component.md b/.specstory/history/2025-07-07_04-30Z-troubleshooting-triangle-display-in-maplibre-component.md new file mode 100644 index 00000000..7688533e --- /dev/null +++ b/.specstory/history/2025-07-07_04-30Z-troubleshooting-triangle-display-in-maplibre-component.md @@ -0,0 +1,96 @@ + + +# Troubleshooting triangle display in MapLibre component (2025-07-07 04:30Z) + +_**User**_ + +为什么这里三角无法正常显示 + +--- + +_**Assistant**_ + +我来看看你选中的代码片段,这是一个用于显示地图标记弹出窗口底部三角形指示器的代码。让我分析一下可能导致三角形无法正常显示的原因: 我看到问题了!你选中的这段代码是用来创建一个指向地图标记的三角形箭头,但是有一个**层级问题**导致三角形无法正常显示。 + +**问题分析:** + +1. **层级问题**:三角形元素 `
` 位于弹出窗口容器内部,但它的 `absolute -bottom-2` 定位使其向下偏移,可能被弹出窗口的 `overflow-hidden` 属性裁剪掉了。 + +2. **容器限制**:弹出窗口容器使用了 `overflow-hidden rounded-lg`,这会裁剪掉超出容器边界的内容。 + +**解决方案:** + +将三角形元素移出弹出窗口容器,或者调整容器的 overflow 属性: + +````tsx +{/* Selected popup */} +{isSelected && ( + +
+ {/* Close button */} + + + {/* Photo */} +
+ +
+ + {/* Info */} +
+ {/* ...existing info content... */} +
+
+ + {/* Arrow pointing to marker - moved outside the main container */} +
+
+
+ +)} +```` + +**主要改动:** + +1. **移除了容器的 `overflow-hidden`**:只在需要的子元素上应用 `overflow-hidden` +2. **将三角形移出主容器**:现在三角形是 `m.div` 的直接子元素,不会被主容器裁剪 +3. **调整了 rounded 样式**:分别对图片和信息区域应用圆角 + +这样修改后,三角形应该能够正常显示了。如果还有问题,可能需要检查一下: + +1. **z-index 层级**:确保三角形没有被其他元素遮挡 +2. **背景色匹配**:确保三角形的背景色与弹出窗口一致 +3. **定位计算**:确保三角形的位置计算正确 + +--- + diff --git a/apps/web/package.json b/apps/web/package.json index 8d65670d..423e594b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,10 +23,12 @@ "@essentials/request-timeout": "1.3.0", "@headlessui/react": "2.2.4", "@lobehub/fluent-emoji": "2.0.0", + "@maplibre/maplibre-gl-geocoder": "^1.9.0", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-context-menu": "2.2.15", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.15", + "@radix-ui/react-hover-card": "1.1.14", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-scroll-area": "1.2.9", "@radix-ui/react-slot": "1.2.3", @@ -49,6 +51,7 @@ "i18next-browser-languagedetector": "8.2.0", "immer": "10.1.1", "jotai": "2.12.5", + "maplibre-gl": "^5.6.1", "masonic": "4.1.0", "motion": "12.19.1", "ofetch": "1.4.1", @@ -60,6 +63,7 @@ "react-i18next": "15.5.3", "react-image-gallery": "1.4.0", "react-intersection-observer": "9.16.0", + "react-map-gl": "^8.0.4", "react-remove-scroll": "2.7.1", "react-router": "7.6.2", "react-scan": "0.3.4", diff --git a/apps/web/src/components/ui/button/GlassButton.tsx b/apps/web/src/components/ui/button/GlassButton.tsx new file mode 100644 index 00000000..aaede37a --- /dev/null +++ b/apps/web/src/components/ui/button/GlassButton.tsx @@ -0,0 +1,47 @@ +import type { + HTMLMotionProps, +} from 'motion/react' +import { m } from 'motion/react' +import type { FC, PropsWithChildren } from 'react' + +import { clsxm } from '~/lib/cn' +import { Spring } from '~/lib/spring' + +export const GlassButton: FC & PropsWithChildren> = ( + props, +) => { + return ( + + {/* Glass effect overlay */} +
+ + {/* Icon container */} +
{props.children}
+ + {/* Subtle inner shadow for depth */} +
+ + ) +} diff --git a/apps/web/src/components/ui/hover-card/index.tsx b/apps/web/src/components/ui/hover-card/index.tsx new file mode 100644 index 00000000..d7f91d5d --- /dev/null +++ b/apps/web/src/components/ui/hover-card/index.tsx @@ -0,0 +1,53 @@ +import * as HoverCardPrimitive from '@radix-ui/react-hover-card' +import { m } from 'motion/react' +import * as React from 'react' + +import { clsxm } from '~/lib/cn' +import { Spring } from '~/lib/spring' + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = ({ + ref, + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.RefObject | null> +}) => ( + + + + {props.children} + + + +) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardContent, HoverCardTrigger } diff --git a/apps/web/src/components/ui/lazy-image/index.tsx b/apps/web/src/components/ui/lazy-image/index.tsx new file mode 100644 index 00000000..bd9f8b83 --- /dev/null +++ b/apps/web/src/components/ui/lazy-image/index.tsx @@ -0,0 +1,91 @@ +import { useCallback, useState } from 'react' +import { useInView } from 'react-intersection-observer' + +import { Thumbhash } from '~/components/ui/thumbhash' +import { clsxm } from '~/lib/cn' + +export interface LazyImageProps { + src: string + alt: string + thumbHash?: string | null + className?: string + style?: React.CSSProperties + onLoad?: () => void + onError?: () => void + // Intersection observer options + rootMargin?: string + threshold?: number +} + +export const LazyImage = ({ + src, + alt, + thumbHash, + className, + style, + onLoad, + onError, + rootMargin = '50px', + threshold = 0.1, +}: LazyImageProps) => { + const [isLoaded, setIsLoaded] = useState(false) + const [hasError, setHasError] = useState(false) + + const { ref, inView } = useInView({ + triggerOnce: true, + rootMargin, + threshold, + }) + + const handleLoad = useCallback(() => { + setIsLoaded(true) + onLoad?.() + }, [onLoad]) + + const handleError = useCallback(() => { + setHasError(true) + onError?.() + }, [onError]) + + const shouldLoadImage = inView && !hasError + + return ( +
+ {/* Thumbhash placeholder */} + {thumbHash && !isLoaded && ( + + )} + + {/* Actual image */} + {shouldLoadImage && ( + {alt} + )} + + {/* Error state */} + {hasError && ( +
+ + Failed to load image + +
+ )} +
+ ) +} diff --git a/apps/web/src/components/ui/map/1.png b/apps/web/src/components/ui/map/1.png new file mode 100644 index 0000000000000000000000000000000000000000..81d13cbb22cfeeb9e4db69e776263387a452fe1b GIT binary patch literal 27890 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%0AV)w{nn;|V56bt%i)c7 zdS$&9VrgL@l>`gQHaCZC(oXhoAFkanvA*(U)gGtRFXy(tm2ieltealQ*)AvL^4n_A z$CSG76yLvm&aT(&OQCGdZXfP-k5g+V{a&}19rSJ?#S*C)2g9k24Vg;S+7jKjo7A}_ zJ$!R)dIQjPr&&|8k2f$S6&0)xhhT^7?Cf9GuP!gMpYQmc-8R}uT_<|IPf&N+mtLB< z7ORW8zS$p7-Jg_h+$P7HD37fU&T&Y#e*S*8Sv=&U7)DYrXXSeEsFRsnhn5VR@7w zNt0huQE~OWvBAUPa`xl>nbx=XFxQs`hlVr24;#VH6HBa114bG9he4Pj#z~fzqPlIo zTw$4iu7rHI&9;qjKOU$@@_e0BoozV5pSAkkA@heSb@@DP4hxjd=fTXo3(rfD;9b3y zU*&h#l(kV%ymnhajZIDR>suUH1IN(;wFi>4ok={O&y$#eeXqt+RdmPOJ|FKFkKLuA zcwRreXcBSPE;nB{d=bRdxq7I2FlS2{yYa^)c(29(+ul6ku;OyJlS9cemD; zjTvrn!`AW8+{7&khp@w(#C|$1N=!z>n*fZPB4lP{buY9PJDA@Ve*!Q^d~$+rzDzcg zV~ICgov&(a9ncc%^Aw1mQE0JHTkCy(O2173R0O~ySF)dbxXpdV(RR8PKA}6eCf5YN zY}tJXDw@h7|FgO7x-L!@#o6OUanRD#6vFc=`-y^V1Docvl-c!9c^D9OH*3w zwLf|nJn6gPHPWHY*PCo(^ zrGnW+b5~c+NALQ+S|zi_)dL#h-<#Hj`2Z(h#Emuz_-~$qMGI z?Pj@tQcwY(?=TXURwGm1%YG<6kL{m`g&0+C2Q=HXzQ>YQ`!ltSh5?&jR^W6sMPMj5 z=hFrs;QP^wUKRqSMtZZ0|EbnMSe%K5)~{*Xp1c*t67RQ1S^?j7_;vi`nrV}U-_&!2 z*35w29^R6_>JpdcxMV2DYb*)lzpC|e3`n-<^?bA$K@p&t(Le6p4YQ9CV!x?A-nziz%W$Kw}x2P=lJJa`y z>S9{2?OP_>jgGQkX1khQ&(bg8p9=9Zcn?<-oR?D(sI;?J`u9#fVCel$oJ6_Bk|}1} z5b$_XB$}#&J*NInknKOs6;vdug6rkT@l<~Q#XADSAdeLEU!doS1JjNJ>=NZFPHH z)OO9@FW0o~OwMsXP=Ckiw%7I9H}<%T7d>9={!o^3eV!?nEW0udrcV?b!|cB5jlA3Z zeBb0cE@JnB*1oJONCz`~<#FDP-sXzM;TFJVu>nXKJ*PnNJ|SwgIgxkXj8q<2De%y2 znc|S%ra8A>teh@!ysejcRa8{E;q3n@qr<6ka|hFZ9&l5b5OSR952r7U#p9O9-Q5O~ z&`3k@J=Oda>3$lpX6h4K8H&ZPEg)GlNEj~VaV}(;7834w?0P*~$o6jTOjYtT9+%YcGVbHrZV;eY`9bb#S zh8e(gT*xSGub~)D=0R-VUN#V@AQBYIACGr;H)^vyqwtVLuhj6Ybdq50loG-| z54xYI!qCcC2r^0LXq6rryDjf-dexT?E6=*TT-`!dh;~SsKMWx6uX?}H2i?oX3w3~c z^_{^KMinhk6Xs(QOzEm6$#c|_tbF!Bd^xor^di>#t($Hj>pyKfo=fO|Y=7{6T*tN? zem-^A8x!6jU7$%RPpG+s7|(6K9ek>X5M6z|UN&bDG+QnIY%#an$q&W*#pRnZz}6+m z^}rzCeDGy&xQUB$v4FbVk}W{I;Hw`pW*GMd1g@_6p7P*aWLoxeYTwzhQK}XjB=a?(!81 zo2fA2IS@nck%5S-BZ(3IDZmOX^uqH%s{!%mBO4qDUkc(gGhvXGY$6wQH7cY!R9RV@ zTw|FVH&ICMv2PFvtPPSGJ!cMN@qSL~;mOLVyHNPPXY`dv8fhLM7(^J}Kv;dV z>%Q&-(9u>PL{^ zm+#=ZvK#a%KrcZ~j$;7xS3>wbwv+pIS4uCx>umDdR+o@}@$j3KbORx^*yFfn*YKl+ zhf12X{hy?n!u)MFSo*5+m~7zcoy_LGG{5xXWU^)AL?Y3cegwgmqHwZsFFWOd>BX}L zGMxpPHGwJlg)l~_R7##7{W&hUHfvfMk`xZ~SuI4``f7wg)BQ(nI$#F;N zvkRq#0k%*<>&6gB8TKSNsNe8NDX|Twkit>7B|_!&%Q+kT-iVQ<^5`W?`XqM{%^g3= zaTb*pr|d~bzahryI2jr;W05>*d@hOdO2G5J(W$rAwVqzIE6){aMooX*q;?A(s*PRA zWUviDs=d&}_dA+tYh~f@1|aJb9e%}f0a*S?;RFff4q>4s9^` z!=8_Dv584{mLp}=A&pEVV>p-z2(A$NoZk@H>QkLr6*%l(#H1GN?br1veds>g+E%X2 zYm-KV)md<(yO?0yNC2~A=$T~#S*~Yd{c?^%Ew^p3U-q*iI+bm`7*f`2_iY#N&hq-< za9#Jdn+35~uqQ$wQV$NUA8pR3?p!-?x>WeU6Yzq;?tmm2ZSglGG<=4{>wzB-Wl4jk^5_idG`c?@3 zm#8e`<?S)DyLYS13lJGbgE@kQtg!ZHwPKBbcicJ?o zs{T_noLKNpK(u~g!GGIKJq+}3+^ZBJEIm0Z=E1GS`NoRvUQUv%x*rv^BjWdurjvCm zegiw&wEZ>5$>LVWgS5b1yxfsfz)+&&1Uu(oE?`Gv+%7tG$N~@Bl!WyPtL++eqON+7 z-e&nHiB3Y$_G*)LBs;vMc>(-bV#(wbsRtFWVQUE40(i}j*rvS!qYM!~S5;Q)rn1Si zveV-ra%bv3>-#@m&^6OT+46ijilu znDy!uH>K;&7O-Medv=f+F&bNdb|)~Rpt2xA2sG64!8efRbr;RCDEg@P_LGxJL(@kF zz~^)B=3%3`BKD{=G;&{}EDt;QpdK9`>$;Po6dOp6+YQvH9cq|!d~%g`!-4}EMM`=g zvT1|fal7w66tarLrMGVbv<#NZ-+TW-4^GOn?Ai11E^A;q37&O5JFLR-E?KHoL=3<^>NDcvJ)LSroKR>va(+&3i(RX zrOSroiLJamJo`t|1VpfM7t-H2>W2`Zu*Qf8r4B0s7RP@lA8}M@ahn;_wCn~`3=b3i z;wC7$BWV?$dTod}V={u|N=IKeNTSX`0=FHG9!5pTd>;x(E$1SJ(*?N1b_90V7pdyEe zD+`W$=KB$~=_Bue@W6_K=ZPWm!h-G9nJsh4vfA&>4-?XoZ>QkeJ(Oc!*^3A=OzDT> zU3fF`O-*X5bi%^?Iq6)13%70pBp3w1AJZ*b1isS5YZEL zYPRWX7Ii7#g0lCI}+QV-ATRquFz)6!^8uPfs zH4k^XN8QHV#0Ub6C;WmNtYlSa>g&MOAPIYK)EP)aB2Wt2dqNWN20uh zRY>}1^TAed-TNt;25(*gM{6#t)dywUhEBN`;{HnnJqy*E$Jl7vv%E%Vx&nvc`&)3=9841X!uDfDdf|kPfCzur9beH3lEd9e2rP zLTn!SE*g1)K|1gEU6Bt?=$R>+5JTarIR=ZAsOjwC9;N zZR(Pt-jqFKhnyEJfgk(2U1rW1TLK4z*u2u%tTu#{rE`NqnXJ|&m9?!QLkv8X?&x$m zj2D4s2I9;uiS<~9rGqqBvqf$23D1+CZ=dy_UCvP9-%oC~0^w%{xWr9H7w$|2OC7n( z^nnLCoLpR{C26~izC3qLjl@@n{uz%Wkd0>AYYC38dnTc_&ok1cNjnP}u9wR%I9xH_Trr=S@ zR~%LidLG8=EeHxL$8PS#gdER3qeN%&y859^J&p_834#(hJn+}`6YuU5zhRc(BdzLm zBhB7Uz9W+XCfr`O(=B~Tqob}eFP&LLiuK~h;>@Iuae|LTY;l;9({=#Z-B*<1hlKB% zEV|B{f3=^ge4!y%by$linukIq>|rp~2rmDFv~vROWa z(TDcIU&)o1I9#b(?e-Lx^A*(LrJ|{zYvi&Z(?&xr0O{M>$6~ig^-Ms}e}61wPVTwnel2L3x@|h3@8a z`Q-?d3j5&jiDgBP8?CFi&|k%MTIM%vGqLg0d&qGD9c(D%Qr$~$($jT2mFCKLdx*SJ z#PkV6PPgKfa^YueAZ~Itd&@mf`w|P+#8_`x2@f$Y9*NmJyR39Q%{B@ebajLbQr{!- zd}wcUD}EXt#wZn9T=U`O`hKvVr>Y3#3cFk?Z!zX^t7IGX%OVOfV}2Qa%TPsTe49C< zpfNcp`51HGWSL8X*gx6TS^_}Spd~fwiwCH`QIVXoU_75N^t?)NrJK)s3D+P(5rPd< z5X#HFLY#*&K5)l1Hx-`hX5Syqyf$8}Rs2S~yk~DUqlzy^T;eINgQ=0y%81KS>o4E= zeceJTUk&@N&wW}Z%Y&*ij~bk-ma(!k zFJ~aQoe9L!u7KtHWcSna=Eh;KC5`9wXjNcg06*2EZ`@J?Uk6tQT235B0Ll(o(Uj#l z#Y-D5aMjv%rS2)EuiHs6iLLq&EpdTQIz#vnYRr=SFetvt5CjC80{B=E;#8GbzW3I1^!9Ng$VDGd6qb0#*40SDkP0<2R0WPdf6E%QV*EPv zwKw<*{%45CLxuPl5fx53pOY-Xh09{9OEr|k~c|1Sz# zXrOaNs?A5cq}(3M&UuPRg0R^N#JVHQ2`xJ>?Dmz~JiFi&WZ6s<31(4QRQN@Rh@d}X zfM98IG|t#h! zR#<_Yqj@bh&ZrTrY_y~NX+|7QVPD|ou{5&ulRqrKcVu1DoqNB=8}xKdpPB>;WEfG7 zvWg@&-IqSApLE0k(x42(>w{VF!m{d*9(E$zS9r0?e?agM0aBj=i8fbhMgr%b3V|C` z;AbVfrhU{yze=upEQdo$vmV4NIx^}q`LDY-6#cJH5&q^HLih4Gwm6|Tz57T}B*o#O zj|{PxzZm;MLVAyMvrKA7dwO`3wX~>(?@KVejkT9Sgdj!*RxmR1C{$KNdXlK$}tf7xe5H3gV4<=0ZFheT0DR~+6Vq^LGl%o~PYTFr0AI66Kn z?t0LnwwL`u#X<%l@Y4(fQ_Q+*{wEx+7`wXUsfY4TM^QNEWw7X# z&QLUWJ%(k-N%jv2Kx{5$4qQdty~&f|Xu^NO20%Vsq4RdVXbS^zyFj&eK68VYmd=f* zHeH_2@nX$4Mc!QqAc({b0K^Cr2NE7@xZB#=Zd|JJ8Akj8 zlspG}(m2NS{r!o0bZMzk>%$^(Ki8xA2JA?K?n%~gi+qn^Fr8dI!(Dg;0Hsy*rNP12 zP(p~j!YGlgWCblHVbGD^lz0lFMxK^u?OPI8;OO}8>itmseSfnB>vl;6DG##Ep<}f0 z2bj1-EYtb(`DNj~9f8{kh2juxjXAR0kc^&0lv}x_vxI}j^Fg*_Jzbxd)NHq`cwPpL zW^8%_MGSWF24L*jkJb_Huq*XW7P^pz))Brab8vE&S4rV}r^9QHj3|UX z1P#nlBN@{NDd<&WfKm`b?oomO`yi-B9YEMO`rtznj4e_UM0>zgN`Iqb6?K*sv;d9I zht@=hd}?JX$%;s$YdW`G-rmTD=wnct(YmFnuMWG2f z(W9%P$`8*08qY5CVu(IYbuNz2^8;!EuG|FuE?6DRh=Y%KTr}aWVd;g4o-!~VZl$IJ zfL>E53qLMglvIuKAW@P}*@0I5BLZptE9&=^Mi{>l)FCzD9$}bS6MJVsvMs%tTTo2D zNBVKr3NZ2u)r!QCRsTA>U-pdJzPy+oS|J}=1Sr`2Ec?EKf(ZDXlsQr%;Pjj$ublMMXm`#R=@j zISu|;BA72K^3%<|A17Ci4tSt}->DnDb8no?{Gd35I4P0{z^8Y@-L2F#!D)XIaBR1@ zI6$fqkn&@3%G{AUud*S1?YHYm|-{d>77}fb?m)acyA(4KJ}2lCa8t zgs`;NK^!C6*kH3P)&e4m$jWfEbb-4DM7yIA$MDF&8=^`>*EBID6rm4T5OWw{+c>ag_pGgf)Aac-NcYAQ>?VK+Cu z*o+Z;rP6zAE@-0lz`pg=30h?_Ci36X@9Zh)p`IOYqt>@@u5r=KoToZ$APQbE7Io#I zQNXH&R9B2)P8pKc3@D}Z z(7eqNPY#nl5_MKHo$=X1jVJH%H?sXPErjd2MD*K09Btiq`hL0`j|Q0x=}2~59Z-?- zYFw#AQmK+z8TM#A&l~!$G>6Zw`RW5%bgt0tA@$VFYNKgtWjH~HWEnc~AZxnu%3e8> z@r)65OF@BFSUx5o7B>(_C1%$YM^>yHNAZAHl?Ck_SOB~dr$JX?VvM{vr|TZJVC+G#%;w`urualBBaHanxR6;=zG#o11uxOQ+X(wdN;}^;$=G zhDL_jN|O1nySZWl7$e~E9QM$Mi&6VnKlSapri-=ZHp5CA{{v5}$u8~-rJ1tip)2N! zoJ#L8l?#i##+2nr{rxh96Pupf30)pI$6#Zc5&K<|?9_gQ==Ir-+=z4xipu<|fP@Wd z1Y>HeiR)2TZso=$H4;C}KoRJ6thENU)_RJ+F)roH4r5peb9}>%kVW`X2OX~uH$x)V z{EG%0!_aHi5qs`IN&}oI`Kf(1g(^*qvBSv72xdxLoSy}&B|p@J42Poj5iyll(z;Tb z`ghYQMk&piG_OV^M$Hy4w5ty74X4S^Q|;eJMhI<6R65!MzRF8*WZHfDJg~kZ_|h3= zuF+rB+gx5W$=MMR(BFqvRy4lqNifb$3g(6kLlojA2KC2|=v(@kxajzJ(xY(b6GG;` z=L=j&h`M|7E@e8U0fDa-X7M)${#s6lfX6Qq422&+DPF*-QQ<&-$CJU+LLP;<;IvJ z`wjqJ7`PuCiow?1Oo_Afe|q|!a$}|a0ieNG78zenY*v0-)3O_MzFMt}>umkr(Yb>p zXuDSR4akoy|BmIdm~S4V9?JyCumvWa{+IVmxOy(|26M6VfRdJW(f)~wf?16N#nAoz zz0)>3I)gexyUxkt;3D@+KW~9f8_dB(>9gbgbrc5nV?Sf8D5~R2!mRnr9Hz!nTlDGC z{-TQ6E5LbC*E6uGJ9iic@~ngX_F0W}KgZ+7d~P6UH(wO5sBp;rtwx-t^$H1#-9U-W z>AFCXS>fgGRyMc~>AdV9^s|=pqBR@Ju2n>#Y7oI@&9Kc8~Z<3R<6YHGH2-@Dy^Gb@^$UK7+$*Ee1 zJR4n(5Zv!?vrMiJTpY$dgw!}e@8PDoa}Sj1#JaxY_bWU+OmKFYlevtsvC5gW2g~{E zcTp<3w}Zh%F8I8$oWKaju_YTX;I>)ULw=hTQu)r&fxD+a) zOY3^2_BKc#U3rt?Y3C~rN>QaPEG&+G#%f#eL(9*DU@6%{-j}h4UlWDg_iCZ7_yL*j5Hx=APg4c zwmdkqBc-Muez!oobq5@HbU4yy^$9aW(a1grRhX2II@%+ zeK?T(W#vhot;QQW)45e4US78Ju=9=0`Vgpa>BaGV0y$#Sp_rqD_J~t$abGq3{@N3u zu2N^5^yb%qU^P^r9dt{ADmqo|2YXT^kIt#ic9(aJ@e1eOC3`0QH=*jTy0NU$l)BPf zPYuqd-CP;7m*e;Em&jEyWKo!+jLfcNU>T^k(;dQhBiOZO!sBTyf!f!Lray_rXl%3Y7tMuw#j%Ox! zQCVjuvN_8`+7o5f-F}+Pf791&x^rqf{P3i{J=Rafq}9ok-5D$5;zzX&xeFj!aI8q8w7UNpdMJiSwg ziURz~yC$pIY+d;CMj^ns<`*bF517+682aqGRk4+YMc$%nC;$BXyw!Iu_0eyi3)X6) z)YdN}viNp**JSI z?xEDB-b~5uPx|aElccKgyN&f6w5xZBVJ8YE4v>@uVe61fp8G4AnkLT5vO=SCbr>(% zfQMU&AKc$H$<;X1+mr5VhzUYdQoqqI;H3w{j=i*LR1uj_GOcrlgd3T6eiWa9eJM~O zcN??l+Gw(C{q78A(xONL5fFte^k~89+91GUy)5+Q6NpQq(ay&x-p7{-%Uo|#)^-;l z3JPtu+tMaqwe*m4#lSK|wKpe^afm<0hVGUefVZ96x`yVG*>=9NtUrZgyGs??j4PWX zSyyNHZk(}8`B@Yu_?Nw(_jJn>#WqWk5`{#=iu&{Rrt0{J^KP_fx?HXcFY35wyF}R> z%=}b1(esToX?^s6Eo*HRhY2CM)gUdeI}#YAmsjn#>sbpcMr~X7{?yjMdD9#cdM$vV znW5@r3kjA%>`Ny*S$~Dpoz$+U6wrQS)9G|!_Ks+OmQ2I3KoiZeYuIT_{YCG`#v8ht zm$V8I&>~H)Cg1p{#J9HNLqjPuHN`H|5^d~=C3m7<&ATW6^z*^<60-SuP#XJLa33eb zt(REwLWkp58}xcLZ;p3yABYl1QusDMX-HXs>OkYy(n{-WE#6#Oc!xYb+&xC|whndc z*tV&L1BBM$LEMakXCrE-U?(XjH?!LGNcXtTxuE8O}-5r7k z4Hn!(?!NzftL}WAsoCm2-KTm^_wU*8zUl@}&ptnQq~pYi(`QS7HcrKMTM zH!H%NRw&poX}gv+GDS$Cn)NmE@pSYi@@WC9>?6_rdNw!;5-K5J4VtX1;)7>g<}gIS z089R5E4Q(F1^bR(&w)seu+tVxsIQ;lcgK{F2di=Hhhn zzK95OQ?*JqHcz)zbkg#Ws)SqZo41(bWhd{~H+f8CYnoTxaGM%_7j0HxR&W|IJer60 z_ggIJL^A^P^pODrdV(DS0;Bceem)Ew)TtGcq@b>HWzEHgKRIX`M_eM z1#IQ4zOCE`l;kLk8JVWh1SwkblbLE+{a_JYPSfH(BucCG%wPsWw_D-2r;i$jn$sE?h@t`|`E@8BgCJkMBM$`M98n(Ap+fl6Al-qzFl5x=sUZfRotss%r1 z`|n6w#-WYNE-tLCgLWsDuAFZV^w!Q!RouP?W>Ji9LeE1e*ij~5oHJMIP|OHOII`;$ zM{hhgO+_)8Nk{QCB#~M#&Czv`=IgVb zZO_Ob3}d^K57K+D5L$=R4&Yp^^&-(`@3-z?J$&^}GVBt#87euiX3zL$ex9pa%R)3| z{$~kaQ9vLGy{kW6z6kT&3E|Qq&E0dv(|q5+Pr9`3<35dMb+EH(6-TV**RT6S{Fm)x z%ti)??Fg`zoI~tQQ3df^icOETf;6&VU*mw7l~|lzNC=6Xc5{{KT-|1XMIDzuVTHyj zM970V6k|Y+pb`pDWCd)xdB^*8CAwBnCu6;{MR=P!n5I+@<&Ja>$W85^X?XL#CaOc8 z8ik%kNzL_j2wr#9gizwI*|xa5>QuGZ-HaOVEU7j^vC!a{fI`m+*E!>IBXM$&fRGUSX-PG7RKIv*a z7I+Cl7v9F%Y5Xg9{V>LgSPk>=fv&G`1Q+Gu91*Rwxp*Mjz3k^|tH8Wmz@&+(?vPm2 z%fM<0zQICe+kD#;p`6`)WPx^VxatcXFAwC-#ha0YHuJnyr^)Q9aLk))dCTFOMn7Sj z?ypV@)=~LGf?2d>CM5ZFg3D9RGhQnVi&_hMpTL7}W#@2L)CqchhWn8|A>)5D#$&{l zx?Hj94Y0<~b=@kfi&J7^Vt*^cyp$yy|)%3LZ-QH5FP63Vb;Fe zZ*6a4aZ4G*Qd)H!GO%hS_0O8F_IrIhyu~QjMenr7HJoa!?2iGll?Br0{cM*rc3`an zdLXVS%`=6;ZY_jaUUs4PTo|ck?G|YbpbIgI772p3wh0JN+P(g5yHwk%mt*B(62miF zSo#bM1)GoFBjQD_=f$Lme)tvDXh%zvwQg6z(Jmn~ag)dx3sDD5&R3nEfk}B?F!pcm z5SR?Qm(b&j3!upothCNgWB#OkN8fflj zqnM#Iw{c~Y<`Jj4y>GQH&(n%8Jd#O$m*4;ef{~lNrn@*7lJmkm{Fy-qYeTfG5eOSO z>ZA&C&j6j{#(JYG+;J(s-h{==2hSA2fKzwRcJ^o?&uwX2JsW|F7<2U|G)r@sRZ7%_ z&srXIkGCzDlZQS=0WoJ*`&bhg%s=u^I?78&k?a74`5qXLCt zhrq^p;>whTq~v?60@p zZf`uEOf7me!xe;|By2U>_DYG+DP|2#p3<}(W#6xTcjIRg{yT-y&saFg+?kuRF4mT4 zI8=y?%N|scr})@1Y!}b_wQ`^7XB$>+1O`vd_YiG_^)|P!Ru^HyG9~wzLTCt7HQTz9 znQX4ZlKPru`@sTDL%U0|ivwUMm62+%9VGmFvWAF|hX{py0m<8@jyw-WW+u9hNL4M> zq_M}99(<+9JT9^?m0Bab`yZ`sQr-EwHaNM(UmyM?#@LU-YKi(JSQi!yUjX})K0F1* zW$P{JqF!wpB$W^bqLNv$p#!_LCOv)DM_=l)H z^+kleKHqt%e?f0h-&ibv{&@b!Vd{OslXsw~QtU^q-@L~!@nGDuZG_9Bv-&P2w?32< zuZ258R?gwt4g4pXI^fxY**d#IZBSwNbME1B<>Jy*3v@tZAi}WO=gexFxu6*Kf~@@X>BQ5g5Ys4jzzR z0Iat$bmXR2)RrFmrO6jI=CXc5OS83#Lo6l`LwYWcM^2lup|1EkMZ}mt@|U=@rUG#t zw(D!GFFrb!K=oe35W}sWWFp%;3N*)*lKDg{$%XIi51ykUCG@f;y?|b=~Dc;AQ zeDi0JL@pjAjx?2p_W?!}28O)-KP>99J2G>-~vF%|Y<=2TWnn0`$Ps7^u+cItAh5YtK?{FM}g7^@^ zli51&1X9~kp+uGu^y?-eH%|S}>UiSf#Ga|NqdE!-^mcZ(OB;ppt2y~>wn&^DTsAf3 zCOv!8sZMCeZcB5$WN2x5wUmXvsx17SjZu-Y9-}invwC|z1+16UuiLE;M|fL2@SA0r zZae!7=y0RUg{Cd=n8(ubMAG;7A6x5*6&=dlrmIA8mf$GCMu=1l;* zp#PLs$C@bQ+82(DFSQWK#T&I<2wq{6h$zwCUW9HX!I(in$bqN3sfuxx$q6BmWF@0d zK0`^kKqPyKO@`2%(TajxhCp zqBJ3!1%cP^6S&=Qf^z>aU7yUjz;z_r%o^~u@diT03dF*xFL4&+OTwBRf||=q>576@ z)gtRSvzjrM#)LX_&foWFr~5k-OoV6ao#>tfYb~jq{XaXIk8aNPTsff35!z;}7H1VB zVC;4PKA+V`nZ5>l{;SP9Cw&sXIB*JQ>P`^_?S0b39JF1uf~;Os``%9DX5jB6e?-V8 zP-+yQ3l0%c!T_x=!@beuqE-=T%9YHLDtWZnulsPJZ#tb8mDm&6>?|TwdVHzQ7qk@pb zCJ8Ui=J}Hw5w9mQ;PTy9mXM}iinjqi27!!3|M7A6NL~^PN_ZTIavT#cR@#wtKYpGc z^)ij!SDie2uIb+5KVWgyREK4VbX`#A>Rqd^lfuKDL=_{*Xngt0nbV-yK{Mbx`^GjO z$xFf`;(Q%h{$YX-j{Td**>3 zNVcjP%Z4%Z7;5%%=NJcC?RQu;q&(qa@u~U*FjAGzadeO>zBri;FuH#C*x~z=G#J); zeJShpCDadpRe@vlb$>Y1c#7Kg$d=@PE@C=I3caMJvDF+PS=U$W+vrx`>u^?eM^wmS zNyjfE7duNxiK!){W>2PX<6zyGW!;Iq>y{=2fB>t`M)c>Y6j6@4zW+(hH zlvBhx;@o1SF~w_h8Z9S3YzzyPO8{Y+se+dAkl-FL#h>U&n%rg7K4B;i34(YYWjxS7 z_|!%3;|J{{Q9A|-Fx*OQ?vS|V3=j^Nj1W9Ddihd@yKYE_^ahKxzc(U>t;myAF1VQF z{Xb78pkt1ecr~Ky&uVKTWt=wrUHaSeQu>nLi%{h4l%7ZxA!Hj^B}MjeZq@ux@s-Z9 z#o!-o{W(`7En(Lk$;_Y9Rj;Q7k3_uxTY>@kLgqtw7%Z#(y3|Xj6l$F#rRU1}18Ti- zThYFI(4FT)i}n{P9h8e3OK+P)*XiI^#^~dhBYb%1d<_L6AiQWhZClAtxHuK(EPruV z`yrld!nGGt(t|Nb!d-)tX$bV~_H6~s8JZoNDK~tLq9W9Pi5B0L@2l$gwSDkJylgx1c&(VQt`;_(AWQC@3p;tyx76I|1GBH?S3cDj6|JU25DAo%ax3&=^1d-}f+-5b$Z8TdY9G|-|JhUubwi&Ds|g7_*CRTQ8j zrvw%)>@JIx^Or*TkDePmi&TeV#H7v+rGLrKOA-@{(lWBLi0K&_cGq)do6E~(f3)i`3SPa}EBOmjo+7NRCA>w`j27Z5BY`f5} z>gNC6at&B9Uz_tK8rbJkUAYYCu4yZ(p;sHTITmBEAqK#Ym{C}ISb zOF|Bzo&*C7r?)iNO2f|cgMI<2{$DFajSzdl=C?JZqkt^n7r8>A*$+JHnJn2@e5P^} z7Dy~}Ss78X-e3(;Z_`cu$@!Db&=scpQFBXWS2N$yCM&IdqVPQrXZvZYc}8AaoKh~A zApQ0fVGE+LjJ_Yk-^2PJDw>kG0XI+;tHy@}u9w@qFDnnrKe89C=d0+L%S9-P>1Kj; z>U|3C{m&F}HGhJ45BZK~^$Rb4JdD^wd%cJpkb_E>T=tB7(fD{&_dh#-%C@(oB!M6% zBX6YXe9o`3)!XS25*71UBMpgMP8%}N5)5-j+S5NGXrDQ76nBnDLl>nxC!Ao!VDHkV zn4mQmgdrTER0y`T!VR8@aJnSh#Z?j08mz_37@o5K|9uJ@*mxq|9BB7ufdI8!Azw~j z*^medwNdL_9?j%RXBxX_BQ3OvN1~L;3;R|<8xLXqp{FM~3=K32T)~xybQ_`}U&4by zi0sl|i53! z?*gswe`!ryscGUjFXEic-;DfjIdL62Nr$z*vnva;``3pvB|F#p=Nb9@#pUxocj58h zdz0jFoLIVQazzNk>gaQ|>PjK)ek$FqrJD1p zjg|X=>(|Eb1MLwj4Ba)k_XhO3j|#Tm@(2eg?3FWpwONs6w;lggMihZPsthvkw0RfX zUPUw#SO2&buw%IA=3MWaD&%uz=@kD{|91f%k^saUJOjB@TT(J#U3=!Mnr?va-?q7wIgVY74)| ze!EI}+i0KK628=JPZt^EQs4J79l?0h^mF8SIU?eONZBI39|d$1pHwXxTI=FNsjPyq zE2I+R{U3&{e?yzGbL_K%(<`IenvnGu%-}K0U6qSmRS{ieR^rZX*(g)F_&*BjWuG(Yv1nce@KKsULVAok0#m*D+uUvI8zqM`_2{iYiJ=+5j?x2 zQ4_Eeq}ztTP1%4C38%Xn^CQjf9c=CQ)`zz?P%Pq*YpozQM!4nC-Q&{>L+owomDfpG zxr=?&&265Io~IUVNZ>5)f&_A5B?L6GLP!3#`4`kP&E`#q>xJmPoUd$UX+4q@1UH6j z{j(Cu92jGZuNqONM5|E}_jrH{1pZ0~9a(Rz7(;uyV?g+Vnq+Pi3+;@YhS#T=elt)e zSB&G`L%DPqLnp(3sZ1FsIMudvEs>#dcYbsoJHa`9WjbgcRisJk=j~sb{&rFH>O(ba zd0s9>?iF<%y*7%zkIt%f9iC}ic=5AcmnM@%m~!`NQm7rNPcJIkJ3v7G$6u9-0zEcv zk8wx0!*V5AxnyXm0yA!l{;`kSUHU{6R8y2$+AH|0q_L}OMeQE)uv2kxwKteEtm2Vf zZoVE0|EGGg0jlO>Xqb1`ANl%?H56U&fdW6YqC^lN98Uj3!WS>{4Yzm+UFgk3BpeD2 zV~y>>z5VP8KsMd7DuZJi8Om|XC>XqSI6|o;k!1vRpax&94GbzEU3AB9X-%J>rdoGI z53_V7FA$Kgw#g%h&g9ljqar*F|4aPo+da-vY z@bmGBE0yofSfw4EsSNkyrOL2m1D&H*9=PiCeSSU3hUw0A-QDU8o)CT?lh%C_OaAa} z{_fy9{>59;UU0swj&|qn`rvEE%1Yei(7DA_ANYum>==A&ZS1Wyi7eQ8wdK9i%wCm3 zw=1G#wi9Q+Qor)h@{Z0-{99>;Zz=w(=;YE;B0E1`eGDZ#LQ~w0H+-^NQyL~*{m&-R7ZUzz#^8IT_QpspSSV^Ai z3nYYd?o-9p6Q3c{$(pLpWz^{BQ+7zx&WOdlf|xS@vqo-nTA-&yokh}0dR+pRb7I7V zRP7;o^z!Zbp>;UB4casx8bWEO`Y=OBU*sOjgJ#FU63vD;R5dmdL8$a`+qeX+s#bvL z_zH0y6DSZB)Qo4Q6CZ^N3L-fQjv8h`C*s|yS#vFoHDO|`2N&P$mAxIF=SNx)Gn)3` zkXY=!!3&5MM`$$!D!a`#+q{o!7)-4w<@>urRX%r@dAY6`H$Qlyvu8*p5@9>7OzXFY zJ!*e%i2)<)K2Y9Z zMYWgKLK&rC%^Dv_klS%Xgy18O#|?*30`o$^_nWPDvxN%m#(+`JQ~uD^St}CL;_lO% z8XLOqs`XxLd}jPHbCik#oSDnHz0kdYbyu|DTmox|nhX_6OAXXTo}6B)qpI1_xs@~v ze3@HT^782pk_lO#nl(cX7(g8`!7Bmh?ci@jNHWFx2N#dqs5K#o6mHftWp+l_z%!Vg z*FM3X+OI;{0CorE9HCI?c)_<%Y8{EI3K5I;G+EJacW$#+l=(&!zLq&Z#7a5AHSJFO zukJ~IF~r&~*5t0zD=mbW`MvIOSFtiO)k_=WGh5WzoL72WR$G+b@0uG!#=P@M1tYb! zbSt0LH9niQlixV(R^)$)Q4YIfWUBBnxGa9IkVqEeY1ph128tj-)ewbm^&UHq#n-s&c5h#`qKcgB zHL7H&fGi(7;SZAF4JVv*F%K!a?ScpGFhpBS47UEF3-_I2k9JwYj<2`+(Fwjhl^j{* zES};U2gSQbb$F|e_7|oH^#Y@>?OEk(f-Jk`$Fdcgn$DSO&wZ@YvlQcV+C^%rw;)*(S zy1u8Z1;UIShBdQk%Vj%vC3rfQ|Elsjt==zcO!uN4d5Msdl1>gG1V&{j3 zkm_oB{c7&O0bre4-8rN7kUdBuQT#UN;rEF?XIrjZ#0fT&o@W&z>ScrQ2!YviH_U9l zqh&GZpPy6;4m#X)JgOJ`AQfl<4l7H>Ze5S}TfNa4t&sZ(M(V;*<%whu5ptjwH5G>zipQyCFkWx%A1411TB;@4s{HvKI$xPaP5g zhm=3+1nahzIy3{G^wQ}}+di5oV{g=J(GtpOLue~yiIhLieQSyD*7jxt@COWA;9#~> znaEp5=rhpyA_6`DKLpk>s6|;5=ettzO2H`dTv<&uEvyVfIRz+G5)db1>_yIbVN({k zEj3o8d}%}8{$A-=aC1VS{)&TQ`-j z{yT>F=vNUO7o_0J6*9Zcm0}MD^9;x`;vo)Wy8b}bLnC;#L|N->f*NhQq?-1_Kv4_X zAbCq7>%4?M0^!!^PyNaYreB^DlsIKN?0}AX0Z+$q{khF3NUk=h53<%+7T%@rj?icc zrJ*huN)I&QWG!35g1EOew&xaRXyxiguGFKfae>&1L~U(RWpguDV^T&o8%sId#AOxf z6&Qoxc2mXzB?=oVl>Hh#8~qSu5MOz5ASa_44PeXI{QuqEs&o58$J1!T}-Jl;xWCw7u zz}p$-KT}uMG;AnNV-hdINVv3re|=lJ&+{dh%uK9u49bFGok{~WzVeG;gcH16hotbW zC&KKMIx3)DGt`62Q~D(}CWcgWF{5L?vEix?!H!W$fJ#1uzRBam_7FgB)W&p0e^X&3 z{E3R-!bbvtRPjX5&$L9(!^0-e9nOnq%(s@d#tcXFUXKr`REWk_96`H$VFy=i7-p5? z;D7MJX_zN=r&Mn_&r(cPpRJ0aQkF3wS)lHcBwg%h>vNqU5+T2 z{|ahBvM$_t)%6F2$v4}MidRe7o!2hRWXyazkO_1QC-kl_1G9#voKZvjA)>#}Y z0-IgJeXv2RHW!_E*SQ1rwsxx2%9u&|w93TW!^?PPC%;B8mw~|`z|BjP38mk*gh>6K z_eBN{A(PmG&UJI9izo#XFpQuaK2rh@2uTd;L4ySQ)RMbpf=b5*{QRcIDojby04LmP z^?g;c{zyVQs4x{o%A1J{eZ>*L^0PQbBP{IS@V(&G2n3C9PH40APKJ5z;%<6>bMZ33 zSP7$vu?e13m%5|(c<^t*K^Wp+zLmtcCPgO1o5*SX58kr~3lfNC4^hNg4}#t%7D;3L z5BDhi!FZ6K%+OPo>p(@Z>dNNMgxQRukdc~;4^)#Di84LakyU`QXjg&&pZAq%pL;i> z!sB`q`>`3#-XaZr;KT=$E}A2xtngA#4#7&LEFIS{0C9x<2Kv58A@l@Vv#YG4;HjPThn) zBP(vLrRej?I^wH(RcUXon9;;EEdXLz5mO(LmNclbm*VY;fni2~K!At`6<7&6Gb*W9 zAFE;dI_on3xayLCM<+1(ukqZX(&Qz-m(2T!V zq((vl{H7@Ufp>72gl2`cN(>s)%+unJUS?tHpoRdU0?U|;7PDBeJ_Py$RKd`7+=T($ z2*@G3Q%2hM(8CAr+A^QynxFAAt+L|QA7flcHhKKDO>0MAOu6TqRb<)d)9sl1>oV02^p#tqU z_i2M9wqDIR?=*i}b+d_~w8S?u-z54DJrzr-A&OE20B_!eTnz6GD^y-GHNfg1-YJ@m ze6V~}QN>r~G<@JMN(>)csfS;~)2bczIOD1TWmzN?6!@A{N#=8ykSq|G7hg;SdNzpk zznlgE$fPJD-n~1(yefucrf4D$o|s}Ny<362OGaehd4WG4ic9a$LpWxW-5u`@YqAXS zjYghzs6HOBm|y}K&`1H>e}|D+-M void +} + +export const ClusterPhotoGrid = ({ + photos, + onPhotoClick, +}: ClusterPhotoGridProps) => { + // 最多显示 6 张照片 + const displayPhotos = photos.slice(0, 6) + const remainingCount = Math.max(0, photos.length - 6) + const { i18n } = useTranslation() + + return ( +
+ {/* 标题 */} +
+

+ {photos.length} 张照片 +

+
点击查看详情
+
+ + {/* 照片网格 */} +
+ {displayPhotos.map((photoMarker, index) => ( + + { + e.stopPropagation() + onPhotoClick?.(photoMarker) + }} + className="block h-full w-full" + > + + + {/* 悬停遮罩 */} +
+ + {/* 悬停图标 */} +
+
+ + + +
+
+ + + ))} + + {/* 更多照片指示器 */} + {remainingCount > 0 && ( + +
+
+ +{remainingCount} +
+
更多
+
+
+ )} +
+ + {/* 位置信息 */} + {photos[0] && ( +
+
+ + + {Math.abs(photos[0].latitude).toFixed(4)}° + {photos[0].latitudeRef || 'N'},{' '} + {Math.abs(photos[0].longitude).toFixed(4)}° + {photos[0].longitudeRef || 'E'} + +
+ + {/* 拍摄时间范围 */} + {(() => { + const dates = photos + .map((p) => p.photo.exif?.DateTimeOriginal) + .filter(Boolean) + .map((d) => new Date(d!)) + .sort((a, b) => a.getTime() - b.getTime()) + + if (dates.length === 0) return null + + const earliest = dates[0] + const latest = dates.at(-1) + const isSameDay = earliest.toDateString() === latest?.toDateString() + + return ( +
+ + + {isSameDay + ? earliest.toLocaleDateString(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : `${earliest.toLocaleDateString(i18n.language, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} - ${latest?.toLocaleDateString(i18n.language, { + month: 'short', + day: 'numeric', + })}`} + +
+ ) + })()} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/ui/map/GenericMap.tsx b/apps/web/src/components/ui/map/GenericMap.tsx new file mode 100644 index 00000000..ab013818 --- /dev/null +++ b/apps/web/src/components/ui/map/GenericMap.tsx @@ -0,0 +1,69 @@ +import * as React from 'react' + +import { getInitialViewStateForMarkers } from '~/lib/map-utils' +import { useMapAdapter } from '~/modules/map/MapProvider' +import type { BaseMapProps, PhotoMarker } from '~/types/map' + +interface GenericMapProps extends Omit { + /** Photo markers to display */ + markers?: PhotoMarker[] + /** Callback when marker is clicked */ + onMarkerClick?: (marker: PhotoMarker) => void + /** Callback when GeoJSON feature is clicked */ + onGeoJsonClick?: (feature: GeoJSON.Feature) => void + /** Callback for geolocation */ + onGeolocate?: (longitude: number, latitude: number) => void +} + +// Default empty array to avoid inline array creation +const DEFAULT_MARKERS: PhotoMarker[] = [] + +/** + * Generic map component that abstracts away the specific map provider + * This component automatically selects the best available provider from context + */ +export const GenericMap: React.FC = ({ + markers = DEFAULT_MARKERS, + onMarkerClick, + onGeoJsonClick, + onGeolocate, + initialViewState, + autoFitBounds = true, + ...props +}) => { + const adapter = useMapAdapter() + // Calculate initial view state from markers (only if autoFitBounds is disabled) + const calculatedInitialViewState = React.useMemo(() => { + if (autoFitBounds) { + // 如果开启自动适配,则使用传入的initialViewState或默认值 + return initialViewState || { longitude: 0, latitude: 0, zoom: 2 } + } + return initialViewState || getInitialViewStateForMarkers(markers) + }, [initialViewState, markers, autoFitBounds]) + + // Prepare handlers for the specific map adapter + const handlers = React.useMemo( + () => ({ + onMarkerClick, + onGeoJsonClick, + onGeolocate, + }), + [onMarkerClick, onGeoJsonClick, onGeolocate], + ) + + if (!adapter) { + return
Map provider not available
+ } + + const { MapComponent } = adapter + + return ( + + ) +} diff --git a/apps/web/src/components/ui/map/MapBackButton.tsx b/apps/web/src/components/ui/map/MapBackButton.tsx new file mode 100644 index 00000000..5eecbabd --- /dev/null +++ b/apps/web/src/components/ui/map/MapBackButton.tsx @@ -0,0 +1,26 @@ +import { startTransition } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' + +import { GlassButton } from '../button/GlassButton' + +export const MapBackButton = () => { + const { t } = useTranslation() + const navigate = useNavigate() + + const handleBack = () => { + startTransition(() => { + navigate('/') + }) + } + + return ( + + + + ) +} diff --git a/apps/web/src/components/ui/map/MapInfoPanel.tsx b/apps/web/src/components/ui/map/MapInfoPanel.tsx new file mode 100644 index 00000000..ee2aca96 --- /dev/null +++ b/apps/web/src/components/ui/map/MapInfoPanel.tsx @@ -0,0 +1,160 @@ +import { m } from 'motion/react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface MapInfoPanelProps { + markersCount: number + bounds?: { + minLat: number + maxLat: number + minLng: number + maxLng: number + } | null +} + +export const MapInfoPanel = ({ markersCount, bounds }: MapInfoPanelProps) => { + const { t } = useTranslation() + const [isExpanded, setIsExpanded] = useState(false) + + return ( + +
+ {/* Header Section */} +
+ + {/* Icon container with enhanced styling */} +
+ +
+ +
+
+

+ {t('explory.explore.map')} +

+ {/* Collapse/Expand Button */} + +
+
+
+
+ + {t('explory.found.locations', { count: markersCount })} + +
+
+
+ +
+ + {/* Coordinates Section - Collapsible */} + + {bounds && ( +
+ {/* Section header */} +
+ + + {t('explory.shooting.range')} + +
+ + {/* Enhanced coordinate cards */} +
+ {/* Min coordinates */} +
+
+ + Southwest +
+
+
+ Lat + + {bounds.minLat.toFixed(6)}° + +
+
+ Lng + + {bounds.minLng.toFixed(6)}° + +
+
+
+ + {/* Max coordinates */} +
+
+ + Northeast +
+
+
+ Lat + + {bounds.maxLat.toFixed(6)}° + +
+
+ Lng + + {bounds.maxLng.toFixed(6)}° + +
+
+
+
+ + {/* Coverage area calculation */} +
+
+ + + Coverage: ~ + {Math.abs( + (bounds.maxLat - bounds.minLat) * + (bounds.maxLng - bounds.minLng) * + 111 * + 111, + ).toFixed(1)}{' '} + km² + +
+
+
+ )} +
+
+ + ) +} diff --git a/apps/web/src/components/ui/map/MapLibre.tsx b/apps/web/src/components/ui/map/MapLibre.tsx new file mode 100644 index 00000000..ec8b83e5 --- /dev/null +++ b/apps/web/src/components/ui/map/MapLibre.tsx @@ -0,0 +1,248 @@ +// Styles +import 'maplibre-gl/dist/maplibre-gl.css' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import Map from 'react-map-gl/maplibre' + +import { calculateMapBounds } from '~/lib/map-utils' +import type { PhotoMarker } from '~/types/map' + +import MAP_STYLE from './MapLibreStyle.json' +import { + ClusterMarker, + clusterMarkers, + DEFAULT_MARKERS, + DEFAULT_STYLE, + DEFAULT_VIEW_STATE, + GeoJsonLayer, + MapControls, + PhotoMarkerPin, +} from './shared' + +export interface PureMaplibreProps { + id?: string + initialViewState?: { + longitude: number + latitude: number + zoom: number + } + markers?: PhotoMarker[] + geoJsonData?: GeoJSON.FeatureCollection + onMarkerClick?: (marker: PhotoMarker) => void + onGeoJsonClick?: (event: any) => void + onGeolocate?: (longitude: number, latitude: number) => void + onClusterClick?: (longitude: number, latitude: number) => void + className?: string + style?: React.CSSProperties + mapRef?: React.RefObject + autoFitBounds?: boolean +} + +export const Maplibre = ({ + id, + initialViewState = DEFAULT_VIEW_STATE, + markers = DEFAULT_MARKERS, + geoJsonData, + onMarkerClick, + onGeoJsonClick, + onGeolocate, + onClusterClick, + className = 'w-full h-full', + style = DEFAULT_STYLE, + mapRef, + autoFitBounds = true, +}: PureMaplibreProps) => { + const [selectedMarkerId, setSelectedMarkerId] = useState(null) + const [currentZoom, setCurrentZoom] = useState(initialViewState.zoom) + const [viewState, setViewState] = useState(initialViewState) + const [isMapLoaded, setIsMapLoaded] = useState(false) + + // Handle marker click + const handleMarkerClick = useCallback( + (marker: PhotoMarker) => { + // Toggle selection: if already selected, deselect; otherwise select + setSelectedMarkerId((prev) => (prev === marker.id ? null : marker.id)) + onMarkerClick?.(marker) + }, + [onMarkerClick], + ) + + // Handle marker close + const handleMarkerClose = useCallback(() => { + setSelectedMarkerId(null) + }, []) + + // Clustered markers + const clusteredMarkers = useMemo( + () => clusterMarkers(markers, currentZoom), + [markers, currentZoom], + ) + + // 计算合适的缩放级别 + const calculateZoomLevel = useCallback((latDiff: number, lngDiff: number) => { + const maxDiff = Math.max(latDiff, lngDiff) + + if (maxDiff < 0.001) return 16 // 非常接近的点 + if (maxDiff < 0.01) return 14 // 很接近的点 + if (maxDiff < 0.1) return 11 // 附近的点 + if (maxDiff < 1) return 8 // 同一城市 + if (maxDiff < 10) return 5 // 同一国家/地区 + return 2 // 跨洲 + }, []) + + // 自动适配到包含所有照片的区域 + const fitMapToBounds = useCallback(() => { + if (!autoFitBounds || markers.length === 0 || !isMapLoaded) return + + const bounds = calculateMapBounds(markers) + if (!bounds) return + + // 如果只有一个点,设置默认缩放级别 + if (markers.length === 1) { + const newViewState = { + longitude: markers[0].longitude, + latitude: markers[0].latitude, + zoom: 13, // 单点时的合理缩放级别 + } + setViewState(newViewState) + setCurrentZoom(newViewState.zoom) + return + } + + // 使用 mapRef 的 fitBounds 方法(推荐方式) + if (mapRef?.current?.getMap) { + // 计算动态padding,确保照片区域控制在窗口的80%内 + // 这意味着每边留出10%的空间作为缓冲区 + const mapContainer = mapRef.current.getContainer() + const containerWidth = mapContainer.offsetWidth + const containerHeight = mapContainer.offsetHeight + + const paddingPercentage = 0.1 // 每边10%的padding + const horizontalPadding = containerWidth * paddingPercentage + const verticalPadding = containerHeight * paddingPercentage + + const padding = { + top: Math.max(verticalPadding, 40), // 最小40px + bottom: Math.max(verticalPadding, 40), + left: Math.max(horizontalPadding, 40), + right: Math.max(horizontalPadding, 40), + } + + try { + const map = mapRef.current.getMap() + map.fitBounds( + [ + [bounds.minLng, bounds.minLat], // 西南角 + [bounds.maxLng, bounds.maxLat], // 东北角 + ], + { + padding, + duration: 800, // 平滑动画 + maxZoom: 15, // 最大缩放级别限制,避免过度放大 + }, + ) + } catch (error) { + console.warn('使用 fitBounds 失败,使用备用方案:', error) + // 备用方案:手动计算视图状态 + fallbackToViewState(bounds) + } + } else { + // mapRef 不可用时的备用方案 + fallbackToViewState(bounds) + } + + function fallbackToViewState( + bounds: ReturnType, + ) { + if (!bounds) return + + const latDiff = bounds.maxLat - bounds.minLat + const lngDiff = bounds.maxLng - bounds.minLng + // 为备用方案也增加一些缓冲,降低一级缩放 + const zoom = Math.max(calculateZoomLevel(latDiff, lngDiff) - 1, 2) + + const newViewState = { + longitude: bounds.centerLng, + latitude: bounds.centerLat, + zoom, + } + + setViewState(newViewState) + setCurrentZoom(zoom) + } + }, [markers, autoFitBounds, isMapLoaded, mapRef, calculateZoomLevel]) + + // 当地图加载完成时触发适配 + const handleMapLoad = useCallback(() => { + setIsMapLoaded(true) + }, []) + + // 当标记点变化时,重新适配边界 + useEffect(() => { + // 延迟执行,确保地图已渲染 + const timer = setTimeout(() => { + fitMapToBounds() + }, 100) + + return () => clearTimeout(timer) + }, [fitMapToBounds]) + + return ( +
+ { + setCurrentZoom(evt.viewState.zoom) + setViewState(evt.viewState) + }} + > + {/* Map Controls */} + + + {/* Photo Markers */} + {clusteredMarkers.map((clusterPoint) => { + if (clusterPoint.properties.cluster) { + // Render cluster marker + return ( + + ) + } else { + // Render individual marker + const { marker } = clusterPoint.properties + if (!marker) return null + + return ( + + ) + } + })} + + {/* GeoJSON Layer */} + {geoJsonData && } + +
+ ) +} diff --git a/apps/web/src/components/ui/map/MapLibreStyle.json b/apps/web/src/components/ui/map/MapLibreStyle.json new file mode 100644 index 00000000..30e12779 --- /dev/null +++ b/apps/web/src/components/ui/map/MapLibreStyle.json @@ -0,0 +1,2837 @@ +{ + "version": 8, + "name": "Dark Matter", + "metadata": { "maputnik:renderer": "mbgljs" }, + "sources": { + "carto": { + "type": "vector", + "url": "https://tiles.basemaps.cartocdn.com/vector/carto.streets/v1/tiles.json" + } + }, + "sprite": "https://tiles.basemaps.cartocdn.com/gl/dark-matter-gl-style/sprite", + "glyphs": "https://tiles.basemaps.cartocdn.com/fonts/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "layout": { "visibility": "visible" }, + "paint": { "background-color": "#0e0e0e", "background-opacity": 1 } + }, + { + "id": "landcover", + "type": "fill", + "source": "carto", + "source-layer": "landcover", + "filter": [ + "any", + ["==", "class", "wood"], + ["==", "class", "grass"], + ["==", "subclass", "recreation_ground"] + ], + "paint": { + "fill-color": { + "stops": [ + [8, "#0e0e0e"], + [9, "#0e0e0e"], + [11, "#0e0e0e"], + [13, "#0e0e0e"], + [15, "#0e0e0e"] + ] + }, + "fill-opacity": 1 + } + }, + { + "id": "park_national_park", + "type": "fill", + "source": "carto", + "source-layer": "park", + "minzoom": 9, + "filter": ["all", ["==", "class", "national_park"]], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": { + "stops": [ + [8, "#0e0e0e"], + [9, "#0e0e0e"], + [11, "#0e0e0e"], + [13, "#0e0e0e"], + [15, "#0e0e0e"] + ] + }, + "fill-opacity": 1, + "fill-translate-anchor": "map" + } + }, + { + "id": "park_nature_reserve", + "type": "fill", + "source": "carto", + "source-layer": "park", + "minzoom": 0, + "filter": ["all", ["==", "class", "nature_reserve"]], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": { + "stops": [ + [8, "#0e0e0e"], + [9, "#0e0e0e"], + [11, "#0e0e0e"], + [13, "#0e0e0e"], + [15, "#0e0e0e"] + ] + }, + "fill-antialias": true, + "fill-opacity": { + "stops": [ + [6, 0.7], + [9, 0.9] + ] + } + } + }, + { + "id": "landuse_residential", + "type": "fill", + "source": "carto", + "source-layer": "landuse", + "minzoom": 6, + "filter": ["any", ["==", "class", "residential"]], + "paint": { + "fill-color": { + "stops": [ + [5, "rgba(0, 0, 0, 0.5)"], + [8, "rgba(0, 0, 0, 0.45)"], + [9, "rgba(0, 0, 0, 0.4)"], + [11, "rgba(0, 0, 0, 0.35)"], + [13, "rgba(0, 0, 0, 0.3)"], + [15, "rgba(0, 0, 0, 0.25)"], + [16, "rgba(0, 0, 0, 0.15)"] + ] + }, + "fill-opacity": { + "stops": [ + [6, 0.6], + [9, 1] + ] + } + } + }, + { + "id": "landuse", + "type": "fill", + "source": "carto", + "source-layer": "landuse", + "filter": ["any", ["==", "class", "cemetery"], ["==", "class", "stadium"]], + "paint": { + "fill-color": { + "stops": [ + [8, "#0e0e0e"], + [9, "#0e0e0e"], + [11, "#0e0e0e"], + [13, "#0e0e0e"], + [15, "#0e0e0e"] + ] + } + } + }, + { + "id": "waterway", + "type": "line", + "source": "carto", + "source-layer": "waterway", + "paint": { + "line-color": "rgba(63, 90, 109, 1)", + "line-width": { + "stops": [ + [8, 0.5], + [9, 1], + [15, 2], + [16, 3] + ] + } + } + }, + { + "id": "boundary_county", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 9, + "maxzoom": 24, + "filter": ["all", ["==", "admin_level", 6], ["==", "maritime", 0]], + "paint": { + "line-color": { + "stops": [ + [4, "#222"], + [5, "#222"], + [6, "#2C353C"] + ] + }, + "line-width": { + "stops": [ + [4, 0.5], + [7, 1] + ] + }, + "line-dasharray": { + "stops": [ + [6, [1]], + [7, [2, 2]] + ] + } + } + }, + { + "id": "boundary_state", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 4, + "filter": ["all", ["==", "admin_level", 4], ["==", "maritime", 0]], + "paint": { + "line-color": { + "stops": [ + [4, "rgba(103, 103, 114, 1)"], + [5, "rgba(103, 103, 114, 1)"], + [6, "rgba(103, 103, 114, 1)"] + ] + }, + "line-width": { + "stops": [ + [4, 0.5], + [7, 1], + [8, 1], + [9, 1.2] + ] + }, + "line-dasharray": { + "stops": [ + [6, [1, 2, 3]], + [7, [1, 2, 3]] + ] + } + } + }, + { + "id": "water", + "type": "fill", + "source": "carto", + "source-layer": "water", + "minzoom": 0, + "maxzoom": 24, + "filter": ["all", ["==", "$type", "Polygon"]], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": "#2C353C", + "fill-antialias": true, + "fill-translate-anchor": "map", + "fill-opacity": 1 + } + }, + { + "id": "water_shadow", + "type": "fill", + "source": "carto", + "source-layer": "water", + "minzoom": 0, + "filter": ["all", ["==", "$type", "Polygon"]], + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": "transparent", + "fill-antialias": true, + "fill-translate-anchor": "map", + "fill-opacity": 1, + "fill-translate": { + "stops": [ + [0, [0, 2]], + [6, [0, 1]], + [14, [0, 1]], + [17, [0, 2]] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "source": "carto", + "source-layer": "aeroway", + "minzoom": 12, + "filter": ["all", ["==", "class", "runway"]], + "layout": { "line-cap": "square" }, + "paint": { + "line-width": { + "stops": [ + [11, 1], + [13, 4], + [14, 6], + [15, 8], + [16, 10] + ] + }, + "line-color": "#111" + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "source": "carto", + "source-layer": "aeroway", + "minzoom": 13, + "filter": ["all", ["==", "class", "taxiway"]], + "paint": { + "line-color": "#111", + "line-width": { + "stops": [ + [13, 0.5], + [14, 1], + [15, 2], + [16, 4] + ] + } + } + }, + { + "id": "tunnel_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "service"], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 1], + [16, 3], + [17, 6], + [18, 8] + ] + }, + "line-opacity": 1, + "line-color": "#1a1a1a" + } + }, + { + "id": "tunnel_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": ["all", ["==", "class", "minor"], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "butt", "line-join": "miter" }, + "paint": { + "line-width": { + "stops": [ + [11, 0.5], + [12, 0.5], + [14, 2], + [15, 4], + [16, 6], + [17, 10], + [18, 14] + ] + }, + "line-opacity": 1, + "line-color": "#1a1a1a" + } + }, + { + "id": "tunnel_sec_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": ["all", ["in", "class", "secondary", "tertiary"], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 0.5], + [12, 1], + [13, 2], + [14, 5], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": "#1a1a1a" + } + }, + { + "id": "tunnel_pri_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 8, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [13, 4], + [14, 6], + [15, 8], + [16, 10], + [17, 14], + [18, 18] + ] + }, + "line-opacity": { + "stops": [ + [5, 0.5], + [7, 1] + ] + }, + "line-color": "#1a1a1a" + } + }, + { + "id": "tunnel_trunk_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [13, 4], + [14, 6], + [15, 8], + [16, 10], + [17, 14], + [18, 18] + ] + }, + "line-opacity": { + "stops": [ + [5, 0.5], + [7, 1] + ] + }, + "line-color": "#232323" + } + }, + { + "id": "tunnel_mot_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + ["==", "class", "motorway"], + ["!=", "ramp", 1], + ["==", "brunnel", "tunnel"] + ], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [12, 4], + [13, 5], + [14, 7], + [15, 9], + [16, 11], + [17, 13], + [18, 22] + ] + }, + "line-opacity": { + "stops": [ + [6, 0.5], + [7, 1] + ] + }, + "line-color": "#232323" + } + }, + { + "id": "tunnel_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "path"], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 0.5], + [16, 1], + [18, 3] + ] + }, + "line-opacity": 1, + "line-color": "#262626", + "line-dasharray": { + "stops": [ + [15, [2, 2]], + [18, [3, 3]] + ] + } + } + }, + { + "id": "tunnel_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "service"], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 2], + [16, 2], + [17, 4], + [18, 6] + ] + }, + "line-opacity": 1, + "line-color": "#161616" + } + }, + { + "id": "tunnel_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "minor"], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 3], + [16, 4], + [17, 8], + [18, 12] + ] + }, + "line-opacity": 1, + "line-color": "rgba(22, 22, 22, 1)" + } + }, + { + "id": "tunnel_sec_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": ["all", ["in", "class", "secondary", "tertiary"], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 2], + [13, 2], + [14, 3], + [15, 4], + [16, 6], + [17, 10], + [18, 14] + ] + }, + "line-opacity": 1, + "line-color": "#161616" + } + }, + { + "id": "tunnel_pri_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 1], + [13, 2], + [14, 4], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "tunnel_trunk_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["!=", "ramp", 1], ["==", "brunnel", "tunnel"]], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [11, 1], + [13, 2], + [14, 4], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": "#161616" + } + }, + { + "id": "tunnel_mot_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + ["==", "class", "motorway"], + ["!=", "ramp", 1], + ["==", "brunnel", "tunnel"] + ], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [10, 1], + [12, 2], + [13, 3], + [14, 5], + [15, 7], + [16, 9], + [17, 11], + [18, 20] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "tunnel_rail", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["all", ["==", "class", "rail"], ["==", "brunnel", "tunnel"]], + "layout": { "visibility": "visible", "line-join": "round" }, + "paint": { + "line-color": "#1a1a1a", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [14, 1], + [15, 1], + [16, 3], + [21, 7] + ] + }, + "line-opacity": 0.5 + } + }, + { + "id": "tunnel_rail_dash", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "filter": ["all", ["==", "class", "rail"], ["==", "brunnel", "tunnel"]], + "layout": { "visibility": "visible", "line-join": "round" }, + "paint": { + "line-color": "#111", + "line-width": { + "base": 1.3, + "stops": [ + [15, 0.5], + [16, 1], + [20, 5] + ] + }, + "line-dasharray": { + "stops": [ + [15, [5, 5]], + [16, [6, 6]] + ] + }, + "line-opacity": 0.5 + } + }, + { + "id": "road_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "service"], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 1], + [16, 3], + [17, 6], + [18, 8] + ] + }, + "line-opacity": 1, + "line-color": "#1c1c1c" + } + }, + { + "id": "road_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": ["all", ["==", "class", "minor"], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 0.5], + [12, 0.5], + [14, 2], + [15, 3], + [16, 4.3], + [17, 10], + [18, 14] + ] + }, + "line-opacity": 1, + "line-color": { + "stops": [ + [13, "rgba(65, 71, 88, 1)"], + [15.7, "rgba(65, 71, 88, 1)"], + [16, "rgba(65, 71, 88, 1)"] + ] + } + } + }, + { + "id": "road_pri_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["==", "ramp", 1]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [12, 2], + [13, 3], + [14, 4], + [15, 5], + [16, 8], + [17, 10] + ] + }, + "line-opacity": { + "stops": [ + [5, 0.5], + [7, 1] + ] + }, + "line-color": "#232323" + } + }, + { + "id": "road_trunk_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["==", "ramp", 1]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [12, 2], + [13, 3], + [14, 4], + [15, 5], + [16, 8], + [17, 10] + ] + }, + "line-opacity": 1, + "line-color": { + "stops": [ + [12, "#1a1a1a"], + [14, "#232323"] + ] + } + } + }, + { + "id": "road_mot_case_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [12, 2], + [13, 3], + [14, 4], + [15, 5], + [16, 8], + [17, 10] + ] + }, + "line-opacity": 1, + "line-color": { + "stops": [ + [12, "#1a1a1a"], + [14, "#232323"] + ] + } + } + }, + { + "id": "road_sec_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": ["all", ["in", "class", "secondary", "tertiary"], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 0.9], + [12, 1.5], + [13, 3], + [14, 5], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": { + "stops": [ + [11, "rgba(65, 71, 88, 1)"], + [12.99, "rgba(65, 71, 88, 1)"], + [13, "rgba(65, 71, 88, 1)"] + ] + } + } + }, + { + "id": "road_pri_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 7, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["!=", "ramp", 1], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [13, 4], + [14, 6], + [15, 8], + [16, 10], + [17, 14], + [18, 18] + ] + }, + "line-opacity": { + "stops": [ + [5, 0.5], + [7, 1] + ] + }, + "line-color": { + "stops": [ + [7, "#1a1a1a"], + [12, "#232323"] + ] + } + } + }, + { + "id": "road_trunk_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["!=", "ramp", 1], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [13, 4], + [14, 6], + [15, 8], + [16, 10], + [17, 14], + [18, 18] + ] + }, + "line-opacity": { + "stops": [ + [5, 0.5], + [7, 1] + ] + }, + "line-color": { + "stops": [ + [5, "#1a1a1a"], + [12, "#232323"] + ] + } + } + }, + { + "id": "road_mot_case_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.7], + [8, 0.8], + [11, 3], + [12, 4], + [13, 5], + [14, 7], + [15, 9], + [16, 11], + [17, 13], + [18, 22] + ] + }, + "line-opacity": { + "stops": [ + [6, 0.5], + [7, 1] + ] + }, + "line-color": { + "stops": [ + [5, "#1a1a1a"], + [12, "#232323"] + ] + } + } + }, + { + "id": "road_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["in", "class", "path", "track"], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 0.5], + [16, 1], + [18, 3] + ] + }, + "line-opacity": 1, + "line-color": "#262626", + "line-dasharray": { + "stops": [ + [15, [2, 2]], + [18, [3, 3]] + ] + } + } + }, + { + "id": "road_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "service"], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 2], + [16, 2], + [17, 4], + [18, 6] + ] + }, + "line-opacity": 1, + "line-color": "#0b0b0b" + } + }, + { + "id": "road_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "minor"], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 3], + [16, 4], + [17, 8], + [18, 12] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "road_pri_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["==", "ramp", 1]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [12, 1], + [13, 1.5], + [14, 2], + [15, 3], + [16, 6], + [17, 8] + ] + }, + "line-opacity": 1, + "line-color": "#0b0b0b" + } + }, + { + "id": "road_trunk_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["==", "ramp", 1]], + "layout": { "line-cap": "square", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [12, 1], + [13, 1.5], + [14, 2], + [15, 3], + [16, 6], + [17, 8] + ] + }, + "line-opacity": 1, + "line-color": "#0b0b0b" + } + }, + { + "id": "road_mot_fill_ramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 12, + "maxzoom": 24, + "filter": ["all", ["==", "class", "motorway"], ["==", "ramp", 1]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [12, 1], + [13, 1.5], + [14, 2], + [15, 3], + [16, 6], + [17, 8] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "road_sec_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": ["all", ["in", "class", "secondary", "tertiary"], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 2], + [13, 2], + [14, 3], + [15, 4], + [16, 6], + [17, 10], + [18, 14] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "road_pri_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["!=", "ramp", 1], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [10, 0.3], + [13, 2], + [14, 4], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": "rgba(83, 86, 102, 1)" + } + }, + { + "id": "road_trunk_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["!=", "ramp", 1], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 1], + [13, 2], + [14, 4], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "road_mot_fill_noramp", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": ["all", ["==", "class", "motorway"], ["!=", "ramp", 1], ["!has", "brunnel"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [10, 1], + [12, 2], + [13, 3], + [14, 5], + [15, 7], + [16, 9], + [17, 11], + [18, 20] + ] + }, + "line-opacity": 1, + "line-color": "rgba(73, 73, 73, 1)" + } + }, + { + "id": "rail", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "filter": ["all", ["==", "class", "rail"], ["!=", "brunnel", "tunnel"]], + "layout": { "visibility": "visible", "line-join": "round" }, + "paint": { + "line-color": "#1a1a1a", + "line-width": { + "base": 1.3, + "stops": [ + [13, 0.5], + [14, 1], + [15, 1], + [16, 3], + [21, 7] + ] + } + } + }, + { + "id": "rail_dash", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "filter": ["all", ["==", "class", "rail"], ["!=", "brunnel", "tunnel"]], + "layout": { "visibility": "visible", "line-join": "round" }, + "paint": { + "line-color": "#111", + "line-width": { + "base": 1.3, + "stops": [ + [15, 0.5], + [16, 1], + [20, 5] + ] + }, + "line-dasharray": { + "stops": [ + [15, [5, 5]], + [16, [6, 6]] + ] + } + } + }, + { + "id": "bridge_service_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "service"], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 1], + [16, 3], + [17, 6], + [18, 8] + ] + }, + "line-opacity": 1, + "line-color": "#1c1c1c" + } + }, + { + "id": "bridge_minor_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": ["all", ["==", "class", "minor"], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "butt", "line-join": "miter" }, + "paint": { + "line-width": { + "stops": [ + [11, 0.5], + [12, 0.5], + [14, 2], + [15, 3], + [16, 4.3], + [17, 10], + [18, 14] + ] + }, + "line-opacity": 1, + "line-color": { + "stops": [ + [13, "#161616"], + [15.7, "#161616"], + [16, "#1c1c1c"] + ] + } + } + }, + { + "id": "bridge_sec_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": ["all", ["in", "class", "secondary", "tertiary"], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "butt", "line-join": "miter" }, + "paint": { + "line-width": { + "stops": [ + [11, 0.5], + [12, 1.5], + [13, 3], + [14, 5], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": { + "stops": [ + [11, "#1a1a1a"], + [12.99, "#1a1a1a"], + [13, "#232323"] + ] + } + } + }, + { + "id": "bridge_pri_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 8, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [13, 4], + [14, 6], + [15, 8], + [16, 10], + [17, 14], + [18, 18] + ] + }, + "line-opacity": { + "stops": [ + [5, 0.5], + [7, 1] + ] + }, + "line-color": { + "stops": [ + [8, "#1a1a1a"], + [12, "#232323"] + ] + } + } + }, + { + "id": "bridge_trunk_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [13, 4], + [14, 6], + [15, 8], + [16, 10], + [17, 14], + [18, 18] + ] + }, + "line-opacity": { + "stops": [ + [5, 0.5], + [7, 1] + ] + }, + "line-color": { + "stops": [ + [5, "#1a1a1a"], + [12, "#232323"] + ] + } + } + }, + { + "id": "bridge_mot_case", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 5, + "maxzoom": 24, + "filter": [ + "all", + ["==", "class", "motorway"], + ["!=", "ramp", 1], + ["==", "brunnel", "bridge"] + ], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [6, 0.5], + [7, 0.8], + [8, 1], + [11, 3], + [12, 4], + [13, 5], + [14, 7], + [15, 9], + [16, 11], + [17, 13], + [18, 22] + ] + }, + "line-opacity": { + "stops": [ + [6, 0.5], + [7, 1] + ] + }, + "line-color": { + "stops": [ + [5, "#1a1a1a"], + [10, "#232323"] + ] + } + } + }, + { + "id": "bridge_path", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "path"], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 0.5], + [16, 1], + [18, 3] + ] + }, + "line-opacity": 1, + "line-color": "#262626", + "line-dasharray": { + "stops": [ + [15, [2, 2]], + [18, [3, 3]] + ] + } + } + }, + { + "id": "bridge_service_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "service"], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 2], + [16, 2], + [17, 4], + [18, 6] + ] + }, + "line-opacity": 1, + "line-color": "#0b0b0b" + } + }, + { + "id": "bridge_minor_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 15, + "maxzoom": 24, + "filter": ["all", ["==", "class", "minor"], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [15, 3], + [16, 4], + [17, 8], + [18, 12] + ] + }, + "line-opacity": 1, + "line-color": "#0b0b0b" + } + }, + { + "id": "bridge_sec_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 13, + "maxzoom": 24, + "filter": ["all", ["in", "class", "secondary", "tertiary"], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 2], + [13, 2], + [14, 3], + [15, 4], + [16, 6], + [17, 10], + [18, 14] + ] + }, + "line-opacity": 1, + "line-color": "#0b0b0b" + } + }, + { + "id": "bridge_pri_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": ["all", ["==", "class", "primary"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [11, 1], + [13, 2], + [14, 4], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": "#0b0b0b" + } + }, + { + "id": "bridge_trunk_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 11, + "maxzoom": 24, + "filter": ["all", ["==", "class", "trunk"], ["!=", "ramp", 1], ["==", "brunnel", "bridge"]], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-width": { + "stops": [ + [11, 1], + [13, 2], + [14, 4], + [15, 6], + [16, 8], + [17, 12], + [18, 16] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "bridge_mot_fill", + "type": "line", + "source": "carto", + "source-layer": "transportation", + "minzoom": 10, + "maxzoom": 24, + "filter": [ + "all", + ["==", "class", "motorway"], + ["!=", "ramp", 1], + ["==", "brunnel", "bridge"] + ], + "layout": { "line-cap": "butt", "line-join": "round" }, + "paint": { + "line-width": { + "stops": [ + [10, 1], + [12, 2], + [13, 3], + [14, 5], + [15, 7], + [16, 9], + [17, 11], + [18, 20] + ] + }, + "line-opacity": 1, + "line-color": "rgba(65, 71, 88, 1)" + } + }, + { + "id": "building", + "type": "fill", + "source": "carto", + "source-layer": "building", + "layout": { "visibility": "visible" }, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [15.5, "transparent"], + [16, "transparent"] + ] + }, + "fill-antialias": true + } + }, + { + "id": "building-top", + "type": "fill", + "source": "carto", + "source-layer": "building", + "layout": { "visibility": "visible" }, + "paint": { + "fill-translate": { + "base": 1, + "stops": [ + [14, [0, 0]], + [16, [-2, -2]] + ] + }, + "fill-outline-color": "#0e0e0e", + "fill-color": "rgba(57, 57, 57, 1)", + "fill-opacity": { + "base": 1, + "stops": [ + [13, 0], + [16, 1] + ] + } + } + }, + { + "id": "boundary_country_outline", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 6, + "maxzoom": 24, + "filter": ["all", ["==", "admin_level", 2], ["==", "maritime", 0]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": "#2C353C", + "line-opacity": 0.5, + "line-width": 8, + "line-offset": 0 + } + }, + { + "id": "boundary_country_inner", + "type": "line", + "source": "carto", + "source-layer": "boundary", + "minzoom": 0, + "filter": ["all", ["==", "admin_level", 2], ["==", "maritime", 0]], + "layout": { "line-cap": "round", "line-join": "round" }, + "paint": { + "line-color": { + "stops": [ + [4, "rgba(92, 94, 94, 1)"], + [5, "rgba(96, 96, 96, 1)"], + [6, "rgba(102, 102, 102, 1)"] + ] + }, + "line-opacity": 1, + "line-width": { + "stops": [ + [3, 1], + [6, 1.5] + ] + }, + "line-offset": 0 + } + }, + { + "id": "waterway_label", + "type": "symbol", + "source": "carto", + "source-layer": "waterway", + "filter": ["all", ["has", "name"], ["==", "class", "river"]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "symbol-placement": "line", + "symbol-spacing": 300, + "symbol-avoid-edges": false, + "text-size": { + "stops": [ + [9, 8], + [10, 9] + ] + }, + "text-padding": 2, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-offset": { + "stops": [ + [6, [0, -0.2]], + [11, [0, -0.4]], + [12, [0, -0.6]] + ] + }, + "text-letter-spacing": 0, + "text-keep-upright": true + }, + "paint": { + "text-color": "rgba(164, 164, 164, 1)", + "text-halo-color": "#181818", + "text-halo-width": 1 + } + }, + { + "id": "watername_ocean", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 0, + "maxzoom": 5, + "filter": ["all", ["has", "name"], ["==", "$type", "Point"], ["==", "class", "ocean"]], + "layout": { + "text-field": "{name}", + "symbol-placement": "point", + "text-size": { + "stops": [ + [0, 13], + [2, 14], + [4, 18] + ] + }, + "text-font": [ + "Montserrat Medium Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-max-width": 6, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "rgba(109, 123, 129, 1)", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1, + "text-halo-blur": 0 + } + }, + { + "id": "watername_sea", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 5, + "filter": ["all", ["has", "name"], ["==", "$type", "Point"], ["==", "class", "sea"]], + "layout": { + "text-field": "{name}", + "symbol-placement": "point", + "text-size": 12, + "text-font": [ + "Montserrat Medium Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-max-width": 6, + "text-letter-spacing": 0.1 + }, + "paint": { + "text-color": "#3c3c3c", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1, + "text-halo-blur": 0 + } + }, + { + "id": "watername_lake", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "minzoom": 4, + "filter": ["all", ["has", "name"], ["==", "$type", "Point"], ["==", "class", "lake"]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [13, "{name}"] + ] + }, + "symbol-placement": "point", + "text-size": { + "stops": [ + [13, 9], + [14, 10], + [15, 11], + [16, 12], + [17, 13] + ] + }, + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-line-height": 1.2, + "text-padding": 2, + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto" + }, + "paint": { + "text-color": "rgba(155, 155, 155, 1)", + "text-halo-color": "#181818", + "text-halo-width": 1, + "text-halo-blur": 1 + } + }, + { + "id": "watername_lake_line", + "type": "symbol", + "source": "carto", + "source-layer": "water_name", + "filter": ["all", ["has", "name"], ["==", "$type", "LineString"]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [13, "{name}"] + ] + }, + "symbol-placement": "line", + "text-size": { + "stops": [ + [13, 9], + [14, 10], + [15, 11], + [16, 12], + [17, 13] + ] + }, + "text-font": [ + "Montserrat Regular Italic", + "Open Sans Italic", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "symbol-spacing": 350, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-line-height": 1.2 + }, + "paint": { + "text-color": "#444", + "text-halo-color": "#181818", + "text-halo-width": 1, + "text-halo-blur": 1 + } + }, + { + "id": "place_hamlet", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 12, + "maxzoom": 16, + "filter": ["any", ["==", "class", "neighbourhood"], ["==", "class", "hamlet"]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [14, "{name}"] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [13, 8], + [14, 10], + [16, 11] + ] + }, + "icon-image": "", + "icon-offset": [16, 0], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [0.2, 0.2], + "text-transform": { + "stops": [ + [12, "none"], + [14, "uppercase"] + ] + } + }, + "paint": { + "text-color": "rgba(182, 180, 180, 1)", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "rgba(53, 52, 52, 1)", + "text-halo-width": 1 + } + }, + { + "id": "place_suburbs", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 12, + "maxzoom": 16, + "filter": ["all", ["==", "class", "suburb"]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [13, "{name}"] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [12, 9], + [13, 10], + [14, 11], + [15, 12], + [16, 13] + ] + }, + "icon-image": "", + "icon-offset": [16, 0], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [0.2, 0.2], + "text-transform": { + "stops": [ + [8, "none"], + [12, "uppercase"] + ] + } + }, + "paint": { + "text-color": "#666", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_villages", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 10, + "maxzoom": 16, + "filter": ["all", ["==", "class", "village"]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [13, "{name}"] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [10, 9], + [12, 10], + [13, 11], + [14, 12], + [16, 13] + ] + }, + "icon-image": "", + "icon-offset": [16, 0], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [0.2, 0.2], + "text-transform": "none" + }, + "paint": { + "text-color": "rgba(154, 153, 153, 1)", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_town", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 14, + "filter": ["all", ["==", "class", "town"]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [13, "{name}"] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [8, 10], + [9, 10], + [10, 11], + [13, 14], + [14, 15] + ] + }, + "icon-image": "", + "icon-offset": [16, 0], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [0.2, 0.2], + "text-transform": "none" + }, + "paint": { + "text-color": "rgba(204, 208, 228, 1)", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_country_2", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 3, + "maxzoom": 10, + "filter": ["all", ["==", "class", "country"], [">=", "rank", 3], ["has", "iso_a2"]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [3, 10], + [5, 11], + [6, 12], + [7, 13], + [8, 14] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": { + "stops": [ + [3, "rgba(157, 157, 157, 1)"], + [5, "rgba(114, 114, 114, 1)"], + [6, "rgba(112, 112, 112, 1)"] + ] + }, + "text-halo-color": "#111", + "text-halo-width": 1 + } + }, + { + "id": "place_country_1", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 2, + "maxzoom": 7, + "filter": ["all", ["==", "class", "country"], ["<=", "rank", 2]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [3, 11], + [4, 12], + [5, 13], + [6, 14] + ] + }, + "text-transform": "uppercase", + "text-max-width": { + "stops": [ + [2, 6], + [3, 6], + [4, 9], + [5, 12] + ] + } + }, + "paint": { + "text-color": { + "stops": [ + [3, "rgba(158, 182, 189, 1)"], + [5, "rgba(118, 126, 137, 1)"], + [6, "rgba(120, 141, 147, 1)"] + ] + }, + "text-halo-color": "#111", + "text-halo-width": 1 + } + }, + { + "id": "place_state", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 5, + "maxzoom": 10, + "filter": ["all", ["==", "class", "state"], ["<=", "rank", 4]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [5, 12], + [7, 14] + ] + }, + "text-transform": "uppercase", + "text-max-width": 9 + }, + "paint": { + "text-color": "rgba(203, 230, 230, 1)", + "text-halo-color": "#111", + "text-halo-width": 0 + } + }, + { + "id": "place_continent", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 0, + "maxzoom": 2, + "filter": ["all", ["==", "class", "continent"]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-transform": "uppercase", + "text-size": 14, + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-justify": "center", + "text-keep-upright": false + }, + "paint": { + "text-color": "rgba(135, 164, 179, 1)", + "text-halo-color": "#111", + "text-halo-width": 1 + } + }, + { + "id": "place_city_r6", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 15, + "filter": ["all", ["==", "class", "city"], [">=", "rank", 6]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [13, "{name}"] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [8, 12], + [9, 13], + [10, 14], + [13, 17], + [14, 20] + ] + }, + "icon-image": "", + "icon-offset": [16, 0], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [0.2, 0.2], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "rgba(168, 176, 180, 1)", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_city_r5", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 8, + "maxzoom": 15, + "filter": ["all", ["==", "class", "city"], [">=", "rank", 0], ["<=", "rank", 5]], + "layout": { + "text-field": { + "stops": [ + [8, "{name_en}"], + [13, "{name}"] + ] + }, + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [8, 14], + [10, 16], + [13, 19], + [14, 22] + ] + }, + "icon-image": "", + "icon-offset": [16, 0], + "text-anchor": "center", + "icon-size": 1, + "text-max-width": 10, + "text-keep-upright": true, + "text-offset": [0.2, 0.2], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "rgba(211, 228, 236, 1)", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 6, + "maxzoom": 7, + "filter": ["all", ["==", "class", "city"], ["<=", "rank", 7]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [16, 5], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [0.2, 0.2] + }, + "paint": { + "text-color": "rgba(174, 191, 207, 1)", + "icon-color": "rgba(94, 105, 106, 1)", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r4", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 5, + "maxzoom": 7, + "filter": ["all", ["==", "class", "city"], ["<=", "rank", 4]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [16, 5], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [0.2, 0.2] + }, + "paint": { + "text-color": "rgba(233, 239, 246, 1)", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_r2", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 4, + "maxzoom": 7, + "filter": ["all", ["==", "class", "city"], ["<=", "rank", 2]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [16, 5], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [0.2, 0.2] + }, + "paint": { + "text-color": "rgba(175, 194, 217, 1)", + "icon-color": "rgba(131, 164, 189, 1)", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_city_dot_z7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 7, + "maxzoom": 8, + "filter": ["all", ["!has", "capital"], ["!in", "class", "country", "state"]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [16, 5], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [0.2, 0.2] + }, + "paint": { + "text-color": "rgba(160, 179, 191, 1)", + "icon-color": "rgba(113, 128, 147, 1)", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "place_capital_dot_z7", + "type": "symbol", + "source": "carto", + "source-layer": "place", + "minzoom": 7, + "maxzoom": 8, + "filter": ["all", [">", "capital", 0]], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 12, + "icon-image": "circle-11", + "icon-offset": [16, 5], + "text-anchor": "right", + "icon-size": 0.4, + "text-max-width": 8, + "text-keep-upright": true, + "text-offset": [0.2, 0.2], + "text-transform": "uppercase" + }, + "paint": { + "text-color": "rgba(177, 201, 214, 1)", + "icon-color": "#666", + "icon-translate-anchor": "map", + "text-halo-color": "#222", + "text-halo-width": 1 + } + }, + { + "id": "poi_stadium", + "type": "symbol", + "source": "carto", + "source-layer": "poi", + "minzoom": 15, + "filter": ["all", ["in", "class", "stadium", "cemetery", "attraction"], ["<=", "rank", 3]], + "layout": { + "text-field": "{name}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [15, 8], + [17, 9], + [18, 10] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#515151", + "text-halo-color": "#151515", + "text-halo-width": 1 + } + }, + { + "id": "poi_park", + "type": "symbol", + "source": "carto", + "source-layer": "poi", + "minzoom": 15, + "filter": ["all", ["==", "class", "park"]], + "layout": { + "text-field": "{name}", + "text-font": [ + "Montserrat Medium", + "Open Sans Bold", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [15, 8], + [17, 9], + [18, 10] + ] + }, + "text-transform": "uppercase" + }, + "paint": { + "text-color": "#515151", + "text-halo-color": "#151515", + "text-halo-width": 1 + } + }, + { + "id": "roadname_minor", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 16, + "filter": ["all", ["in", "class", "minor", "service"]], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": 9, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": 200, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center" + }, + "paint": { + "text-color": "rgba(181, 180, 180, 1)", + "text-halo-color": "#111", + "text-halo-width": 1 + } + }, + { + "id": "roadname_sec", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 15, + "filter": ["all", ["in", "class", "secondary", "tertiary"]], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [15, 9], + [16, 11], + [18, 12] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": 200, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center" + }, + "paint": { + "text-color": "rgba(146, 146, 146, 1)", + "text-halo-color": "rgba(34, 34, 34, 1)", + "text-halo-width": 1 + } + }, + { + "id": "roadname_pri", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 14, + "filter": ["all", ["in", "class", "primary"]], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [14, 10], + [15, 10], + [16, 11], + [18, 12] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": { + "stops": [ + [6, 200], + [16, 250] + ] + }, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center", + "text-letter-spacing": { + "stops": [ + [14, 0], + [16, 0.2] + ] + } + }, + "paint": { + "text-color": "rgba(189, 189, 189, 1)", + "text-halo-color": "#111", + "text-halo-width": 1 + } + }, + { + "id": "roadname_major", + "type": "symbol", + "source": "carto", + "source-layer": "transportation_name", + "minzoom": 13, + "filter": ["all", ["in", "class", "trunk", "motorway"]], + "layout": { + "symbol-placement": "line", + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ], + "text-size": { + "stops": [ + [14, 10], + [15, 10], + [16, 11], + [18, 12] + ] + }, + "text-field": "{name}", + "symbol-avoid-edges": false, + "symbol-spacing": { + "stops": [ + [6, 200], + [16, 250] + ] + }, + "text-pitch-alignment": "auto", + "text-rotation-alignment": "auto", + "text-justify": "center", + "text-letter-spacing": { + "stops": [ + [13, 0], + [16, 0.2] + ] + } + }, + "paint": { + "text-color": "#383838", + "text-halo-color": "#111", + "text-halo-width": 1 + } + }, + { + "id": "housenumber", + "type": "symbol", + "source": "carto", + "source-layer": "housenumber", + "minzoom": 17, + "maxzoom": 24, + "layout": { + "text-field": "{housenumber}", + "text-size": { + "stops": [ + [17, 9], + [18, 11] + ] + }, + "text-font": [ + "Montserrat Regular", + "Open Sans Regular", + "Noto Sans Regular", + "HanWangHeiLight Regular", + "NanumBarunGothic Regular" + ] + }, + "paint": { + "text-halo-color": "transparent", + "text-color": "transparent", + "text-halo-width": 0.75 + } + } + ], + "id": "voyager", + "owner": "Carto" +} diff --git a/apps/web/src/components/ui/map/MapLoadingState.tsx b/apps/web/src/components/ui/map/MapLoadingState.tsx new file mode 100644 index 00000000..7a450240 --- /dev/null +++ b/apps/web/src/components/ui/map/MapLoadingState.tsx @@ -0,0 +1,42 @@ +import { m } from 'motion/react' +import { useTranslation } from 'react-i18next' + +export const MapLoadingState = () => { + const { t } = useTranslation() + + return ( +
+ + + 📍 + + + {t('explory.loading.map')} + + + {t('explory.parsing.location')} + + +
+ ) +} diff --git a/apps/web/src/components/ui/map/index.ts b/apps/web/src/components/ui/map/index.ts new file mode 100644 index 00000000..fabdb42b --- /dev/null +++ b/apps/web/src/components/ui/map/index.ts @@ -0,0 +1,5 @@ +export * from './GenericMap' +export * from './MapBackButton' +export * from './MapInfoPanel' +export * from './MapLoadingState' +export * from './shared' diff --git a/apps/web/src/components/ui/map/shared/ClusterMarker.tsx b/apps/web/src/components/ui/map/shared/ClusterMarker.tsx new file mode 100644 index 00000000..879f17bc --- /dev/null +++ b/apps/web/src/components/ui/map/shared/ClusterMarker.tsx @@ -0,0 +1,132 @@ +import { m } from 'motion/react' +import { Marker } from 'react-map-gl/maplibre' + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '~/components/ui/hover-card' +import { LazyImage } from '~/components/ui/lazy-image' + +import { ClusterPhotoGrid } from '../ClusterPhotoGrid' +import type { ClusterMarkerProps } from './types' + +export const ClusterMarker = ({ + longitude, + latitude, + pointCount, + representativeMarker: _representativeMarker, + clusteredPhotos = [], + onClusterClick, +}: ClusterMarkerProps) => { + const size = Math.min(64, Math.max(40, 32 + Math.log(pointCount) * 8)) + + return ( + + + + onClusterClick?.(longitude, latitude)} + > + {/* Subtle pulse ring for attention */} +
+ + {/* Main cluster container */} +
+ {/* Background mosaic of photos */} + {clusteredPhotos.length > 0 && ( +
+ {/* Show up to 4 photos in a mosaic pattern */} + {clusteredPhotos.slice(0, 4).map((photoMarker, index) => { + const positions = [ + { left: '0%', top: '0%', width: '50%', height: '50%' }, + { left: '50%', top: '0%', width: '50%', height: '50%' }, + { left: '0%', top: '50%', width: '50%', height: '50%' }, + { left: '50%', top: '50%', width: '50%', height: '50%' }, + ] + const position = positions[index] + + return ( +
+ +
+ ) + })} + + {/* Overlay for mosaic effect */} +
+
+ )} + + {/* Glass morphism overlay */} +
+ + {/* Count display */} +
+ {pointCount} +
+ + {/* Subtle inner shadow for depth */} +
+
+ + + + +
+ { + // Optional: handle individual photo clicks + // Photo click handling can be implemented here if needed + }} + /> +
+
+ + + ) +} diff --git a/apps/web/src/components/ui/map/shared/GeoJsonLayer.tsx b/apps/web/src/components/ui/map/shared/GeoJsonLayer.tsx new file mode 100644 index 00000000..530285f8 --- /dev/null +++ b/apps/web/src/components/ui/map/shared/GeoJsonLayer.tsx @@ -0,0 +1,23 @@ +import { Layer, Source } from 'react-map-gl/maplibre' + +import type { GeoJsonLayerProps, LayerProps } from './types' + +const DEFAULT_LAYER_STYLE: LayerProps = { + id: 'data', + type: 'fill', + paint: { + 'fill-color': '#0080ff', + 'fill-opacity': 0.5, + }, +} + +export const GeoJsonLayer = ({ + data, + layerStyle = DEFAULT_LAYER_STYLE, +}: GeoJsonLayerProps) => { + return ( + + + + ) +} diff --git a/apps/web/src/components/ui/map/shared/MapControls.tsx b/apps/web/src/components/ui/map/shared/MapControls.tsx new file mode 100644 index 00000000..ce1a9726 --- /dev/null +++ b/apps/web/src/components/ui/map/shared/MapControls.tsx @@ -0,0 +1,113 @@ +import { m } from 'motion/react' +import { useMap } from 'react-map-gl/maplibre' + +import type { MapControlsProps } from './types' + +export const MapControls = ({ onGeolocate }: MapControlsProps) => { + const { current: map } = useMap() + + const handleZoomIn = () => { + if (map) { + const currentZoom = map.getZoom() + map.easeTo({ zoom: currentZoom + 1, duration: 300 }) + } + } + + const handleZoomOut = () => { + if (map) { + const currentZoom = map.getZoom() + map.easeTo({ zoom: currentZoom - 1, duration: 300 }) + } + } + + const handleCompass = () => { + if (map) { + map.easeTo({ bearing: 0, pitch: 0, duration: 500 }) + } + } + + const handleGeolocate = () => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const { longitude, latitude } = position.coords + if (map) { + map.flyTo({ + center: [longitude, latitude], + zoom: 14, + duration: 1000, + }) + } + onGeolocate?.(longitude, latitude) + }, + (error) => { + console.warn('Geolocation error:', error) + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 60000, + }, + ) + } + } + + return ( + + {/* Control Group Container */} +
+ {/* Zoom In */} + + + {/* Divider */} +
+ + {/* Zoom Out */} + +
+ + {/* Compass Button */} +
+ +
+ + {/* Geolocate Button */} +
+ +
+ + ) +} diff --git a/apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx b/apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx new file mode 100644 index 00000000..5a385ec2 --- /dev/null +++ b/apps/web/src/components/ui/map/shared/PhotoMarkerPin.tsx @@ -0,0 +1,286 @@ +import { m } from 'motion/react' +import { Marker } from 'react-map-gl/maplibre' +import { Link } from 'react-router' + +import { GlassButton } from '~/components/ui/button/GlassButton' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '~/components/ui/hover-card' +import { LazyImage } from '~/components/ui/lazy-image' + +import type { PhotoMarkerPinProps } from './types' + +export const PhotoMarkerPin = ({ + marker, + isSelected = false, + onClick, + onClose, +}: PhotoMarkerPinProps) => { + const handleClick = () => { + onClick?.(marker) + } + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation() + onClose?.() + } + + return ( + + + + + {/* Selection ring */} + {isSelected && ( +
+ )} + + {/* Photo background preview */} +
+ + {/* Overlay */} +
+
+ + {/* Main marker container */} +
+ {/* Glass morphism overlay */} +
+ + {/* Camera icon */} + + + {/* Subtle inner shadow for depth */} +
+
+ + + + +
+ {/* Photo header */} +
+ + {/* Gradient overlay */} +
+
+ + {/* Content */} +
+ {/* Title with link */} + +

+ {marker.photo.title || marker.photo.id} +

+ + + + {/* Metadata */} +
+ {marker.photo.exif?.DateTimeOriginal && ( +
+ + + {new Date( + marker.photo.exif.DateTimeOriginal, + ).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + +
+ )} + + {marker.photo.exif?.Make && marker.photo.exif?.Model && ( +
+ + + {marker.photo.exif.Make} {marker.photo.exif.Model} + +
+ )} + +
+
+ + + {Math.abs(marker.latitude).toFixed(4)}° + {marker.latitudeRef || 'N'},{' '} + {Math.abs(marker.longitude).toFixed(4)}° + {marker.longitudeRef || 'E'} + +
+ {marker.altitude !== undefined && ( +
+ + + {marker.altitudeRef === 'Below Sea Level' ? '-' : ''} + {Math.abs(marker.altitude).toFixed(1)}m + +
+ )} +
+
+
+
+ + + + {/* Enhanced popup for selected state */} + {isSelected && ( + +
+ {/* Close button */} + + + + + {/* Photo container */} +
+ + {/* Gradient overlay */} +
+
+ + {/* Content */} +
+ {/* Title with link */} + +

+ {marker.photo.title || marker.photo.id} +

+ + + + {/* Metadata */} +
+ {marker.photo.exif?.DateTimeOriginal && ( +
+ + + {new Date( + marker.photo.exif.DateTimeOriginal, + ).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+ )} + + {marker.photo.exif?.Make && marker.photo.exif?.Model && ( +
+ + + {marker.photo.exif.Make} {marker.photo.exif.Model} + +
+ )} + +
+
+ + + {Math.abs(marker.latitude).toFixed(6)}° + {marker.latitudeRef || 'N'},{' '} + {Math.abs(marker.longitude).toFixed(6)}° + {marker.longitudeRef || 'E'} + +
+ {marker.altitude !== undefined && ( +
+ + + {marker.altitudeRef === 'Below Sea Level' ? '-' : ''} + {Math.abs(marker.altitude).toFixed(1)}m + +
+ )} +
+
+
+
+ + )} + + ) +} diff --git a/apps/web/src/components/ui/map/shared/clustering.ts b/apps/web/src/components/ui/map/shared/clustering.ts new file mode 100644 index 00000000..7a06ea2d --- /dev/null +++ b/apps/web/src/components/ui/map/shared/clustering.ts @@ -0,0 +1,91 @@ +import type { PhotoMarker } from '~/types/map' + +import type { ClusterPoint } from './types' + +/** + * Simple clustering algorithm for small datasets + * @param markers Array of photo markers to cluster + * @param zoom Current zoom level + * @returns Array of cluster points + */ +export function clusterMarkers( + markers: PhotoMarker[], + zoom: number, +): ClusterPoint[] { + if (markers.length === 0) return [] + + // At high zoom levels, don't cluster + if (zoom >= 15) { + return markers.map((marker) => ({ + type: 'Feature' as const, + properties: { marker }, + geometry: { + type: 'Point' as const, + coordinates: [marker.longitude, marker.latitude], + }, + })) + } + + const clusters: ClusterPoint[] = [] + const processed = new Set() + + // Simple distance-based clustering + const threshold = Math.max(0.001, 0.01 / Math.pow(2, zoom - 10)) // Adjust threshold based on zoom + + for (const marker of markers) { + if (processed.has(marker.id)) continue + + const nearby = [marker] + processed.add(marker.id) + + // Find nearby markers + for (const other of markers) { + if (processed.has(other.id)) continue + + const distance = Math.sqrt( + Math.pow(marker.longitude - other.longitude, 2) + + Math.pow(marker.latitude - other.latitude, 2), + ) + + if (distance < threshold) { + nearby.push(other) + processed.add(other.id) + } + } + + if (nearby.length === 1) { + // Single marker + clusters.push({ + type: 'Feature', + properties: { marker }, + geometry: { + type: 'Point', + coordinates: [marker.longitude, marker.latitude], + }, + }) + } else { + // Cluster + const centerLng = + nearby.reduce((sum, m) => sum + m.longitude, 0) / nearby.length + const centerLat = + nearby.reduce((sum, m) => sum + m.latitude, 0) / nearby.length + + clusters.push({ + type: 'Feature', + properties: { + cluster: true, + point_count: nearby.length, + point_count_abbreviated: nearby.length.toString(), + marker: nearby[0], // Representative marker for the cluster + clusteredPhotos: nearby, // All photos in the cluster + }, + geometry: { + type: 'Point', + coordinates: [centerLng, centerLat], + }, + }) + } + } + + return clusters +} diff --git a/apps/web/src/components/ui/map/shared/index.ts b/apps/web/src/components/ui/map/shared/index.ts new file mode 100644 index 00000000..7688b0d6 --- /dev/null +++ b/apps/web/src/components/ui/map/shared/index.ts @@ -0,0 +1,6 @@ +export * from './clustering' +export * from './ClusterMarker' +export * from './GeoJsonLayer' +export * from './MapControls' +export * from './PhotoMarkerPin' +export * from './types' diff --git a/apps/web/src/components/ui/map/shared/types.ts b/apps/web/src/components/ui/map/shared/types.ts new file mode 100644 index 00000000..574455cb --- /dev/null +++ b/apps/web/src/components/ui/map/shared/types.ts @@ -0,0 +1,71 @@ +import type { PhotoMarker } from '~/types/map' + +// LayerProps type definition for compatibility +export interface LayerProps { + id: string + type: 'fill' | 'line' | 'symbol' | 'circle' | 'raster' | 'fill-extrusion' + paint?: Record + layout?: Record +} + +// Clustering utilities +export interface ClusterPoint { + type: 'Feature' + properties: { + cluster?: boolean + cluster_id?: number + point_count?: number + point_count_abbreviated?: string + marker?: PhotoMarker + clusteredPhotos?: PhotoMarker[] + } + geometry: { + type: 'Point' + coordinates: [number, number] + } +} + +// Common interfaces +export interface GeoJsonLayerProps { + data: GeoJSON.FeatureCollection + layerStyle?: LayerProps +} + +export interface MapControlsProps { + onGeolocate?: (longitude: number, latitude: number) => void +} + +export interface PhotoMarkerPinProps { + marker: PhotoMarker + isSelected?: boolean + onClick?: (marker: PhotoMarker) => void + onClose?: () => void +} + +export interface ClusterMarkerProps { + longitude: number + latitude: number + pointCount: number + representativeMarker?: PhotoMarker + clusteredPhotos?: PhotoMarker[] + onClusterClick?: (longitude: number, latitude: number) => void +} + +// Default values +export const DEFAULT_VIEW_STATE = { + longitude: -122.4, + latitude: 37.8, + zoom: 14, +} + +export const DEFAULT_MARKERS: PhotoMarker[] = [] +export const DEFAULT_STYLE = { width: '100%', height: '100%' } + +export const DEFAULT_LAYER_STYLE: LayerProps = { + id: 'data', + type: 'fill', + paint: { + 'fill-color': '#0080ff', + 'fill-opacity': 0.5, + }, +} diff --git a/apps/web/src/lib/map-utils.ts b/apps/web/src/lib/map-utils.ts new file mode 100644 index 00000000..d7c5f37e --- /dev/null +++ b/apps/web/src/lib/map-utils.ts @@ -0,0 +1,206 @@ +import type { PhotoManifestItem } from '@afilmory/builder' + +import type { + GPSCoordinates, + MapBounds, + MapViewState, + PhotoMarker, +} from '~/types/map' +import { GPSDirection } from '~/types/map' + +/** + * GPS coordinate validation function + */ +export function isValidGPSCoordinates( + coords: GPSCoordinates | null, +): coords is GPSCoordinates { + if (!coords) return false + + const { latitude, longitude } = coords + + return ( + typeof latitude === 'number' && + typeof longitude === 'number' && + !Number.isNaN(latitude) && + !Number.isNaN(longitude) && + latitude >= -90 && + latitude <= 90 && + longitude >= -180 && + longitude <= 180 + ) +} + +/** + * Convert PhotoManifestItem to PhotoMarker if it has GPS coordinates in EXIF + */ +export function convertPhotoToMarkerFromEXIF( + photo: PhotoManifestItem, +): PhotoMarker | null { + const { exif } = photo + + if (!exif?.GPSLatitude || !exif?.GPSLongitude) { + return null + } + + // Convert GPS coordinates from EXIF format to decimal degrees + let latitude: number + let longitude: number + + try { + // Handle different EXIF coordinate formats + if (typeof exif.GPSLatitude === 'number') { + latitude = exif.GPSLatitude + } else { + latitude = Number(exif.GPSLatitude) + } + + if (typeof exif.GPSLongitude === 'number') { + longitude = exif.GPSLongitude + } else { + longitude = Number(exif.GPSLongitude) + } + + // Get GPS direction references + const latitudeRef = + exif.GPSLatitudeRef === 'S' || exif.GPSLatitudeRef === 'South' + ? GPSDirection.South + : GPSDirection.North + + const longitudeRef = + exif.GPSLongitudeRef === 'W' || exif.GPSLongitudeRef === 'West' + ? GPSDirection.West + : GPSDirection.East + + // Apply reference direction to coordinates + if (latitudeRef === GPSDirection.South) { + latitude = -latitude + } + + if (longitudeRef === GPSDirection.West) { + longitude = -longitude + } + + // Process altitude information + let altitude: number | undefined + let altitudeRef: 'Above Sea Level' | 'Below Sea Level' | undefined + + if (exif.GPSAltitude && typeof exif.GPSAltitude === 'number') { + altitude = exif.GPSAltitude + altitudeRef = + exif.GPSAltitudeRef === 'Below Sea Level' + ? 'Below Sea Level' + : 'Above Sea Level' + + // Apply altitude reference + if (altitudeRef === 'Below Sea Level') { + altitude = -altitude + } + } + + // Validate coordinates + if ( + Number.isNaN(latitude) || + Number.isNaN(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + return null + } + + return { + id: photo.id, + longitude, + latitude, + altitude, + latitudeRef, + longitudeRef, + altitudeRef, + photo, + } + } catch (error) { + console.warn( + `Failed to parse GPS coordinates for photo ${photo.id}:`, + error, + ) + return null + } +} + +/** + * Convert array of PhotoManifestItem to PhotoMarker array using EXIF data + */ +export function convertPhotosToMarkersFromEXIF( + photos: PhotoManifestItem[], +): PhotoMarker[] { + return photos + .map((photo) => convertPhotoToMarkerFromEXIF(photo)) + .filter((marker): marker is PhotoMarker => marker !== null) +} + +/** + * Calculate the bounds and center point for a set of markers + */ +export function calculateMapBounds(markers: PhotoMarker[]): MapBounds | null { + if (markers.length === 0) { + return null + } + + const latitudes = markers.map((m) => m.latitude) + const longitudes = markers.map((m) => m.longitude) + + const minLat = Math.min(...latitudes) + const maxLat = Math.max(...latitudes) + const minLng = Math.min(...longitudes) + const maxLng = Math.max(...longitudes) + + return { + minLat, + maxLat, + minLng, + maxLng, + centerLat: (minLat + maxLat) / 2, + centerLng: (minLng + maxLng) / 2, + bounds: [ + [minLng, minLat], // Southwest coordinates + [maxLng, maxLat], // Northeast coordinates + ] as [[number, number], [number, number]], + } +} + +/** + * Get initial view state that fits all markers + */ +export function getInitialViewStateForMarkers( + markers: PhotoMarker[], +): MapViewState { + const bounds = calculateMapBounds(markers) + + if (!bounds) { + // Default view if no markers + return { + longitude: -122.4, + latitude: 37.8, + zoom: 10, + } + } + + // Calculate zoom level based on bounds + const latDiff = bounds.maxLat - bounds.minLat + const lngDiff = bounds.maxLng - bounds.minLng + const maxDiff = Math.max(latDiff, lngDiff) + + let zoom = 10 + if (maxDiff < 0.01) zoom = 15 + else if (maxDiff < 0.1) zoom = 12 + else if (maxDiff < 1) zoom = 8 + else if (maxDiff < 10) zoom = 5 + else zoom = 2 + + return { + longitude: bounds.centerLng, + latitude: bounds.centerLat, + zoom, + } +} diff --git a/apps/web/src/modules/gallery/ActionGroup.tsx b/apps/web/src/modules/gallery/ActionGroup.tsx index c06ea80c..c64dce2b 100644 --- a/apps/web/src/modules/gallery/ActionGroup.tsx +++ b/apps/web/src/modules/gallery/ActionGroup.tsx @@ -2,6 +2,7 @@ import { photoLoader } from '@afilmory/data' import { useAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' import { Drawer } from 'vaul' import { gallerySettingAtom } from '~/atoms/app' @@ -311,9 +312,21 @@ const ResponsiveActionButton = ({ export const ActionGroup = () => { const { t } = useTranslation() const [gallerySetting] = useAtom(gallerySettingAtom) + const navigate = useNavigate() return (
+ {/* 地图探索按钮 */} + + {/* 标签筛选按钮 */} + import('~/components/ui/map/MapLibre').then((m) => ({ default: m.Maplibre })), +) +/** + * MapLibre map adapter implementation + * This adapts MapLibre to work with our generic map provider system + */ +export class MapLibreMapAdapter implements MapAdapter { + name = 'maplibre' + + readonly isAvailable: boolean = true + + MapComponent = MapLibreMapComponent + + async initialize(): Promise { + // MapLibre doesn't require additional async initialization + } + + cleanup(): void { + // No cleanup needed for MapLibre + } +} + +/** + * MapLibre map component that integrates with the Map Provider context + * This component reads configuration from the MapProvider context + */ +export const MapLibreMapComponent: React.FC = ({ + id, + initialViewState, + markers, + geoJsonData, + className, + style, + handlers, + autoFitBounds, +}) => { + const mapRef = React.useRef(null) + + // Default map config constants + const DEFAULT_ANIMATION_DURATION = 1000 + const DEFAULT_ZOOM = 14 + + // Handle GeoJSON click + const handleGeoJsonClick = React.useCallback( + ( + event: maplibregl.MapMouseEvent & { + features?: maplibregl.GeoJSONFeature[] + }, + ) => { + if (!handlers?.onGeoJsonClick) return + + const feature = event.features?.[0] + if (feature) { + handlers.onGeoJsonClick(feature as GeoJSON.Feature) + } + }, + [handlers], + ) + + // Fly to location with animation duration from config + const flyToLocation = React.useCallback( + (longitude: number, latitude: number, zoom?: number) => { + mapRef.current?.flyTo({ + center: [longitude, latitude], + duration: DEFAULT_ANIMATION_DURATION, + zoom: zoom || DEFAULT_ZOOM, + }) + }, + [], // No dependencies needed as constants don't change + ) + + // Handle marker click + const handleMarkerClick = React.useCallback( + (marker: PhotoMarker) => { + handlers?.onMarkerClick?.(marker) + }, + [handlers], + ) + + // Handle geolocate + const handleGeolocate = React.useCallback( + (longitude: number, latitude: number) => { + flyToLocation(longitude, latitude) + handlers?.onGeolocate?.(longitude, latitude) + }, + [flyToLocation, handlers], + ) + + return ( + + ) +} + +/** + * Create a MapLibre adapter instance + */ +export const createMapLibreAdapter = (): MapAdapter => { + return new MapLibreMapAdapter() +} diff --git a/apps/web/src/modules/map/MapProvider.tsx b/apps/web/src/modules/map/MapProvider.tsx new file mode 100644 index 00000000..f56fd601 --- /dev/null +++ b/apps/web/src/modules/map/MapProvider.tsx @@ -0,0 +1,152 @@ +import { siteConfig } from '@config' +import { createContext, use, useMemo } from 'react' + +import type { BaseMapProps } from '~/types/map' + +import { createMapLibreAdapter } from './MapLibreAdapter' + +/** + * Defines the interface for a map adapter. + * This allows for different map providers to be used interchangeably. + */ +export interface MapAdapter { + name: string + isAvailable: boolean + initialize: () => Promise + cleanup?: () => void + + MapComponent: React.FC +} + +/** + * Context for providing the current map adapter. + */ +interface MapContextType { + adapter: MapAdapter | null +} + +export const MapContext = createContext(null) + +/** + * Hook to get the current map adapter from the context. + */ +export const useMapAdapter = () => { + const context = use(MapContext) + if (!context) { + throw new Error('useMapAdapter must be used within a MapProvider') + } + return context.adapter +} + +const maplibreAdapter = createMapLibreAdapter() + +const ADAPTERS = [ + { + name: 'maplibre', + adapter: maplibreAdapter, + component: maplibreAdapter.MapComponent, + }, +] + +/** + * Get the preferred map adapter based on configuration + */ +const getPreferredAdapter = () => { + const mapConfig = siteConfig.map + + // If no map configuration is provided, use the first available adapter + if (!mapConfig) { + const adapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null + if (adapter) { + console.info(`Map: Selected default adapter: ${adapter.name}`) + } + return adapter + } + + // If mapConfig is a string (single provider) + if (typeof mapConfig === 'string') { + const adapter = ADAPTERS.find( + (a) => a.name === mapConfig && a.adapter.isAvailable, + ) + if (adapter) { + console.info(`Map: Selected specified adapter: ${adapter.name}`) + return adapter + } + // If specified provider is not available, fall back to first available + const fallbackAdapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null + if (fallbackAdapter) { + console.info( + `Map: Specified adapter '${mapConfig}' not available, using fallback: ${fallbackAdapter.name}`, + ) + } + return fallbackAdapter + } + + // If mapConfig is an array (priority list) + if (Array.isArray(mapConfig)) { + for (const providerName of mapConfig) { + const adapter = ADAPTERS.find( + (a) => a.name === providerName && a.adapter.isAvailable, + ) + if (adapter) { + console.info( + `Map: Selected adapter from priority list: ${adapter.name}`, + ) + return adapter + } + } + // If none of the priority providers are available, use first available + const fallbackAdapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null + if (fallbackAdapter) { + console.info( + `Map: None of the priority providers available, using fallback: ${fallbackAdapter.name}`, + ) + } + return fallbackAdapter + } + + // Default to first available adapter + const adapter = ADAPTERS.find((a) => a.adapter.isAvailable) || null + if (adapter) { + console.info(`Map: Selected default adapter: ${adapter.name}`) + } else { + console.warn('Map: No adapters are available') + } + return adapter +} + +export const MapProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const adapter = useMemo(() => { + const preferredAdapter = getPreferredAdapter() + if (preferredAdapter) { + return { + ...preferredAdapter.adapter, + MapComponent: preferredAdapter.component, + } + } + return null + }, []) + + const value = useMemo(() => ({ adapter }), [adapter]) + + return {children} +} + +/** + * Utility function to get information about all available map adapters + * Useful for debugging and diagnostics + */ +export const getMapAdapterInfo = () => { + return ADAPTERS.map((adapter) => ({ + name: adapter.name, + isAvailable: adapter.adapter.isAvailable, + adapterName: adapter.adapter.name, + })) +} + +/** + * Get the current map configuration from site config + */ +export const getMapConfig = () => siteConfig.map diff --git a/apps/web/src/modules/map/MapSection.tsx b/apps/web/src/modules/map/MapSection.tsx new file mode 100644 index 00000000..461c4e82 --- /dev/null +++ b/apps/web/src/modules/map/MapSection.tsx @@ -0,0 +1,118 @@ +import { photoLoader } from '@afilmory/data' +import { m } from 'motion/react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { + GenericMap, + MapBackButton, + MapInfoPanel, + MapLoadingState, +} from '~/components/ui/map' +import { + calculateMapBounds, + convertPhotosToMarkersFromEXIF, + getInitialViewStateForMarkers, +} from '~/lib/map-utils' +import { MapProvider } from '~/modules/map/MapProvider' +import type { MapBounds, PhotoMarker } from '~/types/map' + +export const MapSection = () => { + return ( + + + + ) +} + +const MapSectionContent = () => { + const { t } = useTranslation() + + // Photo markers state and loading logic + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [markers, setMarkers] = useState([]) + + // Calculate bounds from markers + const bounds = useMemo(() => { + if (markers.length === 0) return null + return calculateMapBounds(markers) + }, [markers]) + + // Load photo markers effect + useEffect(() => { + const loadPhotoMarkersData = async () => { + setIsLoading(true) + setError(null) + + try { + const photos = photoLoader.getPhotos() + const photoMarkers = convertPhotosToMarkersFromEXIF(photos) + + setMarkers(photoMarkers) + console.info(`Found ${photoMarkers.length} photos with GPS coordinates`) + } catch (err) { + const error = + err instanceof Error ? err : new Error('Failed to load photo markers') + setError(error) + console.error('Failed to load photo markers:', error) + } finally { + setIsLoading(false) + } + } + + loadPhotoMarkersData() + }, [setMarkers]) + + // Initial view state calculation + const initialViewState = useMemo( + () => getInitialViewStateForMarkers(markers), + [markers], + ) + + // Show loading state + if (isLoading) { + return + } + + // Show error state + if (error) { + return ( +
+
+
+
+ {t('explory.map.error.title')} +
+

+ {t('explory.map.error.description')} +

+
+
+ ) + } + + return ( +
+ {/* Back button */} + + + {/* Map info panel */} + + + {/* Generic Map component */} + + + +
+ ) +} diff --git a/apps/web/src/pages/(main)/layout.tsx b/apps/web/src/pages/(main)/layout.tsx index 57c65045..b83148a0 100644 --- a/apps/web/src/pages/(main)/layout.tsx +++ b/apps/web/src/pages/(main)/layout.tsx @@ -1,6 +1,6 @@ import { photoLoader } from '@afilmory/data' -import { siteConfig } from '@config' import { useAtomValue, useSetAtom } from 'jotai' +// import { AnimatePresence } from 'motion/react' import { useEffect, useRef } from 'react' import { Outlet, @@ -21,24 +21,11 @@ export const Component = () => { useStateRestoreFromUrl() useSyncStateToUrl() + // const location = useLocation() const isMobile = useMobile() return ( <> - {siteConfig.accentColor && ( -