@mapbox/mcp-server 0.17.0-dev.1 → 0.17.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/commonjs/resources/BaseResource.d.ts +7 -0
  2. package/dist/commonjs/resources/BaseResource.d.ts.map +1 -1
  3. package/dist/commonjs/resources/BaseResource.js +13 -2
  4. package/dist/commonjs/resources/BaseResource.js.map +1 -1
  5. package/dist/commonjs/resources/category-list/CategoryListResource.d.ts +7 -2
  6. package/dist/commonjs/resources/category-list/CategoryListResource.d.ts.map +1 -1
  7. package/dist/commonjs/resources/category-list/CategoryListResource.js +14 -4
  8. package/dist/commonjs/resources/category-list/CategoryListResource.js.map +1 -1
  9. package/dist/commonjs/resources/place-photo/PlacePhotoResource.d.ts +1 -1
  10. package/dist/commonjs/resources/place-photo/PlacePhotoResource.js +2 -2
  11. package/dist/commonjs/resources/place-photo/PlacePhotoResource.js.map +1 -1
  12. package/dist/commonjs/resources/resourceRegistry.d.ts +2 -1
  13. package/dist/commonjs/resources/resourceRegistry.d.ts.map +1 -1
  14. package/dist/commonjs/resources/resourceRegistry.js +10 -5
  15. package/dist/commonjs/resources/resourceRegistry.js.map +1 -1
  16. package/dist/commonjs/resources/tool-response/ToolResponseResource.d.ts +6 -5
  17. package/dist/commonjs/resources/tool-response/ToolResponseResource.d.ts.map +1 -1
  18. package/dist/commonjs/resources/tool-response/ToolResponseResource.js +29 -5
  19. package/dist/commonjs/resources/tool-response/ToolResponseResource.js.map +1 -1
  20. package/dist/commonjs/resources/ui-apps/StaticMapViewerUIResource.d.ts +28 -0
  21. package/dist/commonjs/resources/ui-apps/StaticMapViewerUIResource.d.ts.map +1 -0
  22. package/dist/commonjs/resources/ui-apps/StaticMapViewerUIResource.js +64 -0
  23. package/dist/commonjs/resources/ui-apps/StaticMapViewerUIResource.js.map +1 -0
  24. package/dist/commonjs/resources/ui-apps/mapAppHtml.d.ts.map +1 -1
  25. package/dist/commonjs/resources/ui-apps/mapAppHtml.js +936 -223
  26. package/dist/commonjs/resources/ui-apps/mapAppHtml.js.map +1 -1
  27. package/dist/commonjs/resources/ui-apps/staticMapViewerHtml.d.ts +17 -0
  28. package/dist/commonjs/resources/ui-apps/staticMapViewerHtml.d.ts.map +1 -0
  29. package/dist/commonjs/resources/ui-apps/staticMapViewerHtml.js +180 -0
  30. package/dist/commonjs/resources/ui-apps/staticMapViewerHtml.js.map +1 -0
  31. package/dist/commonjs/tools/render-map-tool/RenderMapTool.d.ts.map +1 -1
  32. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.d.ts +1 -0
  33. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.d.ts.map +1 -1
  34. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.js +10 -6
  35. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.js.map +1 -1
  36. package/dist/commonjs/tools/render-map-tool/RenderMapTool.js +26 -12
  37. package/dist/commonjs/tools/render-map-tool/RenderMapTool.js.map +1 -1
  38. package/dist/commonjs/tools/render-map-tool/RenderMapTool.output.schema.d.ts +1 -0
  39. package/dist/commonjs/tools/render-map-tool/RenderMapTool.output.schema.d.ts.map +1 -1
  40. package/dist/commonjs/tools/resource-reader-tool/ResourceReaderTool.d.ts.map +1 -1
  41. package/dist/commonjs/tools/resource-reader-tool/ResourceReaderTool.js +43 -9
  42. package/dist/commonjs/tools/resource-reader-tool/ResourceReaderTool.js.map +1 -1
  43. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.d.ts +8 -1
  44. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.d.ts.map +1 -1
  45. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.js +113 -90
  46. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.js.map +1 -1
  47. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts +23 -0
  48. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts.map +1 -0
  49. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.js +84 -0
  50. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.js.map +1 -0
  51. package/dist/commonjs/utils/mapAppPayload.d.ts +15 -6
  52. package/dist/commonjs/utils/mapAppPayload.d.ts.map +1 -1
  53. package/dist/commonjs/utils/mapAppPayload.js +2 -1
  54. package/dist/commonjs/utils/mapAppPayload.js.map +1 -1
  55. package/dist/commonjs/utils/mapboxPublicToken.d.ts +5 -1
  56. package/dist/commonjs/utils/mapboxPublicToken.d.ts.map +1 -1
  57. package/dist/commonjs/utils/mapboxPublicToken.js +6 -1
  58. package/dist/commonjs/utils/mapboxPublicToken.js.map +1 -1
  59. package/dist/commonjs/utils/responseRef.d.ts +1 -1
  60. package/dist/commonjs/utils/responseRef.d.ts.map +1 -1
  61. package/dist/commonjs/utils/responseRef.js +1 -1
  62. package/dist/commonjs/utils/responseRef.js.map +1 -1
  63. package/dist/commonjs/version.json +3 -3
  64. package/dist/esm/resources/BaseResource.d.ts +7 -0
  65. package/dist/esm/resources/BaseResource.d.ts.map +1 -1
  66. package/dist/esm/resources/BaseResource.js +13 -2
  67. package/dist/esm/resources/BaseResource.js.map +1 -1
  68. package/dist/esm/resources/category-list/CategoryListResource.d.ts +7 -2
  69. package/dist/esm/resources/category-list/CategoryListResource.d.ts.map +1 -1
  70. package/dist/esm/resources/category-list/CategoryListResource.js +14 -4
  71. package/dist/esm/resources/category-list/CategoryListResource.js.map +1 -1
  72. package/dist/esm/resources/place-photo/PlacePhotoResource.d.ts +1 -1
  73. package/dist/esm/resources/place-photo/PlacePhotoResource.js +2 -2
  74. package/dist/esm/resources/place-photo/PlacePhotoResource.js.map +1 -1
  75. package/dist/esm/resources/resourceRegistry.d.ts +2 -1
  76. package/dist/esm/resources/resourceRegistry.d.ts.map +1 -1
  77. package/dist/esm/resources/resourceRegistry.js +10 -5
  78. package/dist/esm/resources/resourceRegistry.js.map +1 -1
  79. package/dist/esm/resources/tool-response/ToolResponseResource.d.ts +6 -5
  80. package/dist/esm/resources/tool-response/ToolResponseResource.d.ts.map +1 -1
  81. package/dist/esm/resources/tool-response/ToolResponseResource.js +29 -5
  82. package/dist/esm/resources/tool-response/ToolResponseResource.js.map +1 -1
  83. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.d.ts +28 -0
  84. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.d.ts.map +1 -0
  85. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.js +59 -0
  86. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.js.map +1 -0
  87. package/dist/esm/resources/ui-apps/mapAppHtml.d.ts.map +1 -1
  88. package/dist/esm/resources/ui-apps/mapAppHtml.js +936 -223
  89. package/dist/esm/resources/ui-apps/mapAppHtml.js.map +1 -1
  90. package/dist/esm/resources/ui-apps/staticMapViewerHtml.d.ts +17 -0
  91. package/dist/esm/resources/ui-apps/staticMapViewerHtml.d.ts.map +1 -0
  92. package/dist/esm/resources/ui-apps/staticMapViewerHtml.js +177 -0
  93. package/dist/esm/resources/ui-apps/staticMapViewerHtml.js.map +1 -0
  94. package/dist/esm/tools/render-map-tool/RenderMapTool.d.ts.map +1 -1
  95. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.d.ts +1 -0
  96. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.d.ts.map +1 -1
  97. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.js +10 -6
  98. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.js.map +1 -1
  99. package/dist/esm/tools/render-map-tool/RenderMapTool.js +26 -12
  100. package/dist/esm/tools/render-map-tool/RenderMapTool.js.map +1 -1
  101. package/dist/esm/tools/render-map-tool/RenderMapTool.output.schema.d.ts +1 -0
  102. package/dist/esm/tools/render-map-tool/RenderMapTool.output.schema.d.ts.map +1 -1
  103. package/dist/esm/tools/resource-reader-tool/ResourceReaderTool.d.ts.map +1 -1
  104. package/dist/esm/tools/resource-reader-tool/ResourceReaderTool.js +43 -9
  105. package/dist/esm/tools/resource-reader-tool/ResourceReaderTool.js.map +1 -1
  106. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.d.ts +8 -1
  107. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.d.ts.map +1 -1
  108. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.js +114 -91
  109. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.js.map +1 -1
  110. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts +23 -0
  111. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts.map +1 -0
  112. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.js +78 -0
  113. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.js.map +1 -0
  114. package/dist/esm/utils/mapAppPayload.d.ts +15 -6
  115. package/dist/esm/utils/mapAppPayload.d.ts.map +1 -1
  116. package/dist/esm/utils/mapAppPayload.js +2 -1
  117. package/dist/esm/utils/mapAppPayload.js.map +1 -1
  118. package/dist/esm/utils/mapboxPublicToken.d.ts +5 -1
  119. package/dist/esm/utils/mapboxPublicToken.d.ts.map +1 -1
  120. package/dist/esm/utils/mapboxPublicToken.js +6 -1
  121. package/dist/esm/utils/mapboxPublicToken.js.map +1 -1
  122. package/dist/esm/utils/responseRef.d.ts +1 -1
  123. package/dist/esm/utils/responseRef.d.ts.map +1 -1
  124. package/dist/esm/utils/responseRef.js +1 -1
  125. package/dist/esm/utils/responseRef.js.map +1 -1
  126. package/dist/esm/version.json +3 -3
  127. package/package.json +1 -1
@@ -35,24 +35,62 @@ function renderMapAppHtml(params) {
35
35
  <link href="https://api.mapbox.com/mapbox-gl-js/v${glVersion}/mapbox-gl.css" rel="stylesheet">
36
36
  <script src="https://api.mapbox.com/mapbox-gl-js/v${glVersion}/mapbox-gl.js"></script>
37
37
  <style>
38
+ /* Light and dark, following the host's theme (see applyHostContext), or
39
+ the system's when the host sends none. Colors come from the host's own
40
+ style variables (MCP Apps hostContext.styles) when it sends them, so
41
+ the cards match the chat around them; the fallbacks are used
42
+ otherwise. The map, its pins and their name labels stay as they are. */
43
+ :root {
44
+ color-scheme: light;
45
+ --ui-surface: var(--color-background-primary, #ffffff);
46
+ --ui-surface-muted: var(--color-background-secondary, #f1f5f9);
47
+ --ui-text: var(--color-text-primary, #0f172a);
48
+ --ui-text-muted: var(--color-text-secondary, #475569);
49
+ --ui-icon-muted: #64748b;
50
+ --ui-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);
51
+ --ui-error-bg: #ffebee; --ui-error-text: #b71c1c;
52
+ }
53
+ :root[data-theme="dark"] {
54
+ color-scheme: dark;
55
+ --ui-surface: var(--color-background-primary, #0f172a);
56
+ --ui-surface-muted: var(--color-background-secondary, #1e293b);
57
+ --ui-text: var(--color-text-primary, #f1f5f9);
58
+ --ui-text-muted: var(--color-text-secondary, #cbd5e1);
59
+ --ui-icon-muted: #94a3b8;
60
+ --ui-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
61
+ --ui-error-bg: #3b1214; --ui-error-text: #fca5a5;
62
+ }
38
63
  * { margin: 0; padding: 0; box-sizing: border-box; }
39
64
  html, body { width: 100%; height: 100%; overflow: hidden;
40
65
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
41
66
  #map { position: absolute; inset: 0; }
42
67
  #summary {
43
68
  position: absolute; top: 12px; left: 12px; z-index: 10;
44
- background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
69
+ background: var(--ui-surface); color: var(--ui-text);
45
70
  padding: 8px 12px; border-radius: 6px;
46
71
  font-size: 13px; font-weight: 500;
47
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
72
+ box-shadow: var(--ui-shadow);
48
73
  max-width: calc(100% - 24px); white-space: pre-line;
49
74
  }
75
+ /* Bottom offsets keep the Mapbox logo (bottom left) and attribution
76
+ (bottom right) visible, as the Mapbox terms require. */
77
+ #legends {
78
+ position: absolute; bottom: 40px; left: 12px; z-index: 10;
79
+ display: flex; flex-direction: column; align-items: flex-start; gap: 8px;
80
+ }
50
81
  #legend {
51
- position: absolute; bottom: 12px; left: 12px; z-index: 10;
52
- background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
82
+ background: var(--ui-surface); color: var(--ui-text);
53
83
  padding: 8px 12px; border-radius: 6px;
54
- font-size: 11px;
84
+ font-size: 11px; box-shadow: var(--ui-shadow);
85
+ }
86
+ /* Traffic: Fast [4 colors] Slow, below any payload legend. */
87
+ #traffic-legend {
88
+ align-items: center; gap: 8px; padding: 8px; border-radius: 8px;
89
+ background: #fff; color: #05070a; font-size: 14px; line-height: 1.3;
90
+ box-shadow: 0 4px 8px rgba(0,0,0,0.12); white-space: nowrap;
55
91
  }
92
+ #traffic-legend .bar { display: flex; gap: 2px; }
93
+ #traffic-legend .bar span { width: 20px; height: 10px; border-radius: 2px; }
56
94
  #legend .row { display: flex; align-items: center; margin: 2px 0; }
57
95
  #legend .swatch {
58
96
  display: inline-block; width: 14px; height: 14px;
@@ -60,11 +98,11 @@ function renderMapAppHtml(params) {
60
98
  }
61
99
  #loading {
62
100
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
63
- color: #666; font-size: 16px; z-index: 10; pointer-events: none;
101
+ color: var(--ui-text-muted); font-size: 16px; z-index: 10; pointer-events: none;
64
102
  }
65
103
  #error {
66
104
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
67
- color: #d32f2f; background: #ffebee; border-radius: 8px;
105
+ color: var(--ui-error-text); background: var(--ui-error-bg); border-radius: 8px;
68
106
  padding: 20px; max-width: 400px; text-align: center; z-index: 10;
69
107
  }
70
108
  .marker-badge {
@@ -75,51 +113,99 @@ function renderMapAppHtml(params) {
75
113
  box-shadow: 0 2px 6px rgba(0,0,0,0.4);
76
114
  cursor: pointer;
77
115
  }
78
- #side-panel {
79
- position: absolute; top: 12px; right: 12px; bottom: 12px; z-index: 10;
80
- width: 280px; max-width: calc(100% - 24px);
81
- background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
82
- border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
83
- display: flex; flex-direction: column; overflow: hidden;
84
- }
85
- #side-panel .panel-head {
86
- padding: 10px 12px; font-size: 13px; font-weight: 600;
87
- border-bottom: 1px solid rgba(255,255,255,0.12); flex: 0 0 auto;
88
- display: flex; justify-content: space-between; gap: 8px; cursor: pointer;
89
- }
90
- /* Click the heading to collapse the list (see togglePanel). */
91
- #side-panel .panel-head::after { content: '▾'; opacity: 0.7; }
92
- #side-panel.collapsed { bottom: auto; }
93
- #side-panel.collapsed .panel-list { display: none; }
94
- #side-panel.collapsed .panel-head { border-bottom: none; }
95
- #side-panel.collapsed .panel-head::after { content: '▸'; }
96
- #side-panel .panel-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; flex: 1; }
97
- #side-panel .panel-item {
98
- display: flex; align-items: flex-start; gap: 6px; padding: 6px; border-radius: 6px; cursor: pointer;
99
- }
100
- #side-panel .panel-item:hover { background: rgba(255,255,255,0.08); }
101
- /* Only added once a photo has loaded (see loadPanelPhoto), at the end of
102
- the row so names stay aligned whether or not a row has one. */
103
- #side-panel .panel-thumb {
104
- flex: 0 0 auto; width: 40px; height: 40px; border-radius: 6px; margin-left: auto;
116
+ /* Selected place (see selectPlace): the pin inverts, like the card it
117
+ matches. */
118
+ .marker-badge.active {
119
+ background: #fff !important; color: #0f172a !important;
120
+ transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.5);
121
+ }
122
+ /* Satellite / Traffic buttons under the zoom control (see buildLayerToggles). */
123
+ .mapboxgl-ctrl-group.layer-toggles button {
124
+ display: flex; align-items: center; justify-content: center; color: #333;
125
+ }
126
+ .mapboxgl-ctrl-group.layer-toggles button.active { color: #2563eb; background: #dbeafe; }
127
+ .mapboxgl-ctrl-group.layer-toggles button:focus:not(:focus-visible) { box-shadow: none; }
128
+ /* Every carousel place shows its name under the pin, with a white halo
129
+ instead of a box, the way map labels look. */
130
+ .marker-badge .marker-name {
131
+ position: absolute; top: 32px; left: 50%; transform: translateX(-50%);
132
+ white-space: nowrap; font-size: 11px; font-weight: 700; color: #0f172a;
133
+ text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff,
134
+ 0 0 3px #fff;
135
+ pointer-events: none;
136
+ }
137
+ /* A place pin (search results and other places in the carousel): a
138
+ white disc with the place's Maki icon, black when selected. Numbered
139
+ badges stay for markers whose number means something (visit order). */
140
+ .marker-badge.place {
141
+ width: 30px; height: 30px; background: #fff; color: #0f172a;
142
+ border: none; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
143
+ }
144
+ .marker-badge.place svg { width: 16px; height: 16px; display: block; }
145
+ .marker-badge.place.active {
146
+ background: #0f172a !important; color: #fff !important;
147
+ }
148
+ /* Results carousel. Sits above the Mapbox logo and attribution, which
149
+ must stay visible; the legend moves up above it (see showCarousel). */
150
+ #carousel { position: absolute; left: 0; right: 0; bottom: 36px; z-index: 10; }
151
+ #carousel .card-list {
152
+ list-style: none; margin: 0; padding: 4px 32px 6px;
153
+ display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
154
+ scroll-snap-type: x mandatory; scrollbar-width: none;
155
+ }
156
+ #carousel .card-list::-webkit-scrollbar { display: none; }
157
+ #carousel .card {
158
+ flex: 0 0 auto; width: 300px; max-width: calc(100vw - 64px); min-height: 84px;
159
+ scroll-snap-align: center;
160
+ display: flex; align-items: center; gap: 10px; padding: 10px;
161
+ background: var(--ui-surface); color: var(--ui-text);
162
+ border: 2px solid transparent; border-radius: 14px;
163
+ box-shadow: var(--ui-shadow); cursor: pointer;
164
+ }
165
+ #carousel .card.active { border-color: var(--ui-text); }
166
+ /* Added once a photo loads (see loadPanelPhoto), after the text in the
167
+ DOM but shown first. */
168
+ #carousel .card-thumb {
169
+ order: -1; flex: 0 0 auto; width: 64px; height: 64px; border-radius: 10px;
105
170
  background-size: cover; background-position: center;
106
171
  }
107
- /* Visit-order badge, matching the numbered map marker. */
108
- #side-panel .panel-badge {
109
- flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; margin-top: 1px;
172
+ /* Until a photo loads, or when the place has none: the place's icon
173
+ on a muted tile, so every card has a picture. */
174
+ #carousel .card-thumb.empty {
175
+ background: var(--ui-surface-muted); color: var(--ui-icon-muted);
176
+ display: flex; align-items: center; justify-content: center;
177
+ }
178
+ #carousel .card-thumb.empty svg { width: 28px; height: 28px; display: block; }
179
+ /* Matches the numbered pin. */
180
+ #carousel .card-badge {
181
+ flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%;
110
182
  background: #f97316; color: #1a0f04;
111
183
  display: flex; align-items: center; justify-content: center;
112
- font-size: 10px; font-weight: 700;
184
+ font-size: 11px; font-weight: 700;
113
185
  }
114
- #side-panel .panel-name { font-size: 12.5px; font-weight: 600; }
115
- #side-panel .panel-meta { font-size: 11px; color: #cbd5e1; margin-top: 2px; }
186
+ #carousel .card-body { flex: 1 1 auto; min-width: 0; }
187
+ #carousel .card-name {
188
+ font-size: 14px; font-weight: 600;
189
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
190
+ }
191
+ #carousel .card-meta, #carousel .card-extra {
192
+ font-size: 12px; color: var(--ui-text-muted); margin-top: 3px;
193
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
194
+ }
195
+ #carousel .hours-open { color: #15803d; font-weight: 600; }
196
+ #carousel .hours-closed { color: #b91c1c; font-weight: 600; }
197
+ :root[data-theme="dark"] #carousel .hours-open { color: #4ade80; }
198
+ :root[data-theme="dark"] #carousel .hours-closed { color: #f87171; }
116
199
  </style>
117
200
  </head>
118
201
  <body>
119
202
  <div id="map"></div>
120
203
  <div id="summary" style="display:none"></div>
121
- <div id="legend" style="display:none"></div>
122
- <div id="side-panel" style="display:none"></div>
204
+ <div id="legends">
205
+ <div id="legend" style="display:none"></div>
206
+ <div id="traffic-legend" style="display:none"></div>
207
+ </div>
208
+ <div id="carousel" style="display:none"></div>
123
209
  <div id="loading">Loading…</div>
124
210
  <div id="error" style="display:none"></div>
125
211
  ${initialDataScript}
@@ -133,9 +219,11 @@ ${initialDataScript}
133
219
  var errorEl = document.getElementById('error');
134
220
  var summaryEl = document.getElementById('summary');
135
221
  var legendEl = document.getElementById('legend');
136
- var panelEl = document.getElementById('side-panel');
137
- // Populated by buildSearchMiniPayload's marker tagging so the panel (click
138
- // -> flyTo) and Place Details enrichment can find a marker by mapbox_id
222
+ var trafficLegendEl = document.getElementById('traffic-legend');
223
+ var legendsEl = document.getElementById('legends');
224
+ var carouselEl = document.getElementById('carousel');
225
+ // Populated by buildSearchMiniPayload's marker tagging so the carousel (card
226
+ // <-> pin) and Place Details enrichment can find a marker by mapbox_id
139
227
  // without re-querying the map for it.
140
228
  var markersById = {};
141
229
 
@@ -152,6 +240,45 @@ ${initialDataScript}
152
240
  var trackedLayerIds = [];
153
241
  var trackedSourceIds = [];
154
242
  var trackedMarkers = [];
243
+ // Copies of the GeoJSON layers drawn so far. setStyle() drops every
244
+ // source and layer we added (DOM markers survive it), so a style switch
245
+ // from the Satellite button puts these back (see restoreOverlays).
246
+ var drawnLayers = [];
247
+ var currentBaseMapConfig = null;
248
+ var payloadLegend = [];
249
+ var trafficOn = false;
250
+ // Mapbox Traffic v1: live congestion per road segment, refreshed every few
251
+ // minutes. Served from api.mapbox.com, which the page's CSP already allows.
252
+ var TRAFFIC_ID = 'mapbox-live-traffic';
253
+ // Colors from the design's traffic legend (Fast to Slow).
254
+ var TRAFFIC_LEVELS = [
255
+ { value: 'low', label: 'Light traffic', color: '#00c94e' },
256
+ { value: 'moderate', label: 'Moderate traffic', color: '#ff9900' },
257
+ { value: 'heavy', label: 'Heavy traffic', color: '#ff2b3a' },
258
+ { value: 'severe', label: 'Severe traffic', color: '#8b0a18' }
259
+ ];
260
+ var TRAFFIC_MAIN_ROADS = [
261
+ 'motorway', 'motorway_link', 'trunk', 'trunk_link',
262
+ 'primary', 'secondary', 'tertiary', 'link'
263
+ ];
264
+ // 24px filled icons (a globe and a car, as in the design), sized to match
265
+ // the zoom buttons. The globe's lines are cut out in the button color.
266
+ var TOGGLE_ICONS = {
267
+ satellite: '<circle cx="12" cy="12" r="9.5"/>' +
268
+ '<path d="M2.5 12h19M12 2.5a14 14 0 0 1 0 19M12 2.5a14 14 0 0 0 0 19" ' +
269
+ 'fill="none" stroke="#fff" stroke-width="1.6"/>',
270
+ traffic: '<path fill-rule="evenodd" d="M5 11l1.6-4.4A2.4 2.4 0 0 1 8.9 5h6.2' +
271
+ 'a2.4 2.4 0 0 1 2.3 1.6L19 11a2 2 0 0 1 2 2v4a1 1 0 0 1-1 1h-1v1.5' +
272
+ 'a1.5 1.5 0 0 1-3 0V18H8v1.5a1.5 1.5 0 0 1-3 0V18H4a1 1 0 0 1-1-1v-4' +
273
+ 'a2 2 0 0 1 2-2zm2.2 0h9.6l-1.1-3.2a1 1 0 0 0-1-.8H9.3a1 1 0 0 0-1 .8z' +
274
+ 'M7.5 13a1.5 1.5 0 1 0 0 3a1.5 1.5 0 1 0 0-3zm9 0a1.5 1.5 0 1 0 0 3' +
275
+ 'a1.5 1.5 0 1 0 0-3z"/>'
276
+ };
277
+
278
+ // Set by the layer toggle control, which initMap() adds before the rest
279
+ // of this script has run.
280
+ var satelliteButton = null;
281
+ var trafficButton = null;
155
282
 
156
283
  // --- MCP App postMessage protocol -----------------------------------------
157
284
  var messageId = 0;
@@ -190,6 +317,7 @@ ${initialDataScript}
190
317
  }
191
318
 
192
319
  window.addEventListener('message', function(event) {
320
+ if (event.source !== window.parent) return;
193
321
  var message = event.data;
194
322
  if (!message || typeof message !== 'object') return;
195
323
  if (message.id !== undefined && pendingRequests.has(message.id)) {
@@ -203,12 +331,56 @@ ${initialDataScript}
203
331
  handleToolResult(message.params);
204
332
  }
205
333
  if (message.method === 'ui/notifications/host-context-changed' &&
206
- message.params && message.params.displayMode) {
207
- currentDisplayMode = message.params.displayMode;
208
- if (map) setTimeout(function() { map.resize(); }, 100);
334
+ message.params) {
335
+ // Partial updates: only the fields that changed are sent.
336
+ applyHostContext(message.params);
337
+ if (message.params.displayMode) {
338
+ currentDisplayMode = message.params.displayMode;
339
+ if (map) setTimeout(function() { map.resize(); }, 100);
340
+ }
209
341
  }
210
342
  });
211
343
 
344
+ // --- Light/dark theme -----------------------------------------------------
345
+ var rootEl = document.documentElement;
346
+ var hostTheme = null;
347
+
348
+ function applyTheme(theme) {
349
+ if ((theme !== 'light' && theme !== 'dark') || !rootEl) return;
350
+ rootEl.setAttribute('data-theme', theme);
351
+ }
352
+
353
+ // The host's theme wins over the system's; its style variables
354
+ // (--color-background-primary and so on) feed the --ui-* colors.
355
+ function applyHostContext(ctx) {
356
+ if (!ctx || typeof ctx !== 'object') return;
357
+ if (ctx.theme === 'light' || ctx.theme === 'dark') {
358
+ hostTheme = ctx.theme;
359
+ applyTheme(hostTheme);
360
+ }
361
+ var vars = ctx.styles && ctx.styles.variables;
362
+ if (vars && typeof vars === 'object' && rootEl && rootEl.style &&
363
+ typeof rootEl.style.setProperty === 'function') {
364
+ Object.keys(vars).forEach(function(name) {
365
+ if (/^--[a-z0-9-]+$/i.test(name) && typeof vars[name] === 'string') {
366
+ rootEl.style.setProperty(name, vars[name]);
367
+ }
368
+ });
369
+ }
370
+ }
371
+ window.__applyHostContext = applyHostContext;
372
+
373
+ var darkQuery = typeof window.matchMedia === 'function'
374
+ ? window.matchMedia('(prefers-color-scheme: dark)')
375
+ : null;
376
+ function followSystemTheme() {
377
+ if (!hostTheme && darkQuery) applyTheme(darkQuery.matches ? 'dark' : 'light');
378
+ }
379
+ followSystemTheme();
380
+ if (darkQuery && typeof darkQuery.addEventListener === 'function') {
381
+ darkQuery.addEventListener('change', followSystemTheme);
382
+ }
383
+
212
384
  // Set once a host answers ui/initialize, even with an error. A page from
213
385
  // export_map_html_tool framed by some other site never gets an answer.
214
386
  var hostAnswered = false;
@@ -217,8 +389,9 @@ ${initialDataScript}
217
389
  appCapabilities: {},
218
390
  appInfo: { name: 'Mapbox Map', version: '1.0.0' }
219
391
  }).then(
220
- function() {
392
+ function(result) {
221
393
  hostAnswered = true;
394
+ if (result) applyHostContext(result.hostContext);
222
395
  sendNotification('ui/notifications/initialized', {});
223
396
  },
224
397
  function() {
@@ -273,6 +446,7 @@ ${initialDataScript}
273
446
  }
274
447
  map = new mapboxgl.Map(mapOptions);
275
448
  map.addControl(new mapboxgl.NavigationControl(), 'top-left');
449
+ map.addControl(new LayerToggleControl(), 'top-left');
276
450
  map.on('load', function() {
277
451
  mapLoaded = true;
278
452
  if (pendingPayload) {
@@ -405,6 +579,7 @@ ${initialDataScript}
405
579
  } else {
406
580
  summaryEl.style.display = 'none';
407
581
  }
582
+ placeTopLeftControls();
408
583
  if (!map) { loadingEl.style.display = 'none'; return; }
409
584
  if (mapLoaded) render(payload);
410
585
  else pendingPayload = payload;
@@ -428,8 +603,13 @@ ${initialDataScript}
428
603
  trackedLayerIds = [];
429
604
  trackedSourceIds = [];
430
605
  trackedMarkers = [];
606
+ drawnLayers = [];
607
+ // A render without baseMapConfig must not bring back the last one (say,
608
+ // night mode) when the Satellite button switches style.
609
+ currentBaseMapConfig = null;
431
610
  markersById = {};
432
- panelEl.style.display = 'none';
611
+ carouselEl.style.display = 'none';
612
+ legendsEl.style.bottom = '';
433
613
  clearPanel();
434
614
  // Place Details responses still in flight belong to the old render.
435
615
  panelGeneration += 1;
@@ -465,10 +645,105 @@ ${initialDataScript}
465
645
  end: { color: '#ef4444' }
466
646
  };
467
647
 
648
+ // Maki icons (https://github.com/mapbox/maki, CC0), 15x15 path data, for
649
+ // the categories search results most often have. Anything else gets
650
+ // the plain marker.
651
+ var MAKI = {
652
+ 'attraction': 'M6,2C5.446,2,5.2478,2.5045,5,3L4.5,4h-2C1.669,4,1,4.669,1,5.5v5C1,11.331,1.669,12,2.5,12h10 c0.831,0,1.5-0.669,1.5-1.5v-5C14,4.669,13.331,4,12.5,4h-2L10,3C9.75,2.5,9.554,2,9,2H6z M2.5,5C2.7761,5,3,5.2239,3,5.5 S2.7761,6,2.5,6S2,5.7761,2,5.5S2.2239,5,2.5,5z M7.5,5c1.6569,0,3,1.3431,3,3s-1.3431,3-3,3s-3-1.3431-3-3S5.8431,5,7.5,5z M7.5,6.5C6.6716,6.5,6,7.1716,6,8l0,0c0,0.8284,0.6716,1.5,1.5,1.5l0,0C8.3284,9.5,9,8.8284,9,8l0,0C9,7.1716,8.3284,6.5,7.5,6.5 L7.5,6.5z',
653
+ 'bakery': 'M5.294,4.382,6,9.5a.979.979,0,0,0,1,1H8a.979.979,0,0,0,1-1l.706-5.118C9.706,3,7.5,3,7.5,3S5.291,3,5.294,4.382ZM3.5,5C2,5,2,6,2,6l1,4H4.5a.793.793,0,0,0,.794-.765L4.5,5Zm-2,2.5a1.533,1.533,0,0,0-1.059.412A1.366,1.366,0,0,0,0,8.794V11H.882A1.02,1.02,0,0,0,2,10ZM11.5,5C13,5,13,6,13,6l-1,4H10.5a.793.793,0,0,1-.794-.765L10.5,5Zm2,2.5a1.533,1.533,0,0,1,1.059.412A1.366,1.366,0,0,1,15,8.794V11h-.882A1.02,1.02,0,0,1,13,10Z',
654
+ 'bank': 'M1,3C0.446,3,0,3.446,0,4v7c0,0.554,0.446,1,1,1h13c0.554,0,1-0.446,1-1V4c0-0.554-0.446-1-1-1H1z M1,4h1.5 C2.7761,4,3,4.2239,3,4.5S2.7761,5,2.5,5S2,4.7761,2,4.5L1.5,5C1.7761,5,2,5.2239,2,5.5S1.7761,6,1.5,6S1,5.7761,1,5.5V4z M7.5,4 C8.8807,4,10,5.567,10,7.5l0,0C10,9.433,8.8807,11,7.5,11S5,9.433,5,7.5S6.1193,4,7.5,4z M12.5,4H14v1.5C14,5.7761,13.7761,6,13.5,6 S13,5.7761,13,5.5S13.2239,5,13.5,5L13,4.5C13,4.7761,12.7761,5,12.5,5S12,4.7761,12,4.5S12.2239,4,12.5,4z M7.5,5.5 c-0.323,0-0.5336,0.1088-0.6816,0.25h1.3633C8.0336,5.6088,7.823,5.5,7.5,5.5z M6.625,6C6.5795,6.091,6.5633,6.1711,6.5449,6.25 h1.9102C8.4367,6.1711,8.4205,6.091,8.375,6H6.625z M6.5,6.5v0.25h2V6.5H6.5z M6.5,7v0.25h2V7H6.5z M6.5,7.5v0.25h2V7.5H6.5z M6.5,8 L6.25,8.25h2L8.5,8H6.5z M6,8.5c0,0,0.0353,0.1024,0.1016,0.25H8.375L8,8.5H6z M1.5,9C1.7761,9,2,9.2239,2,9.5S1.7761,10,1.5,10 L2,10.5C2,10.2239,2.2239,10,2.5,10S3,10.2239,3,10.5S2.7761,11,2.5,11H1V9.5C1,9.2239,1.2239,9,1.5,9z M6.2383,9 C6.2842,9.0856,6.3144,9.159,6.375,9.25h2.2676C8.7092,9.1121,8.75,9,8.75,9H6.2383z M13.5,9C13.7761,9,14,9.2239,14,9.5V11h-1.5 c-0.2761,0-0.5-0.2239-0.5-0.5s0.2239-0.5,0.5-0.5s0.5,0.2239,0.5,0.5l0.5-0.5C13.2239,10,13,9.7761,13,9.5S13.2239,9,13.5,9z M6.5664,9.5c0.0786,0.0912,0.1647,0.1763,0.2598,0.25h1.4199C8.3462,9.6727,8.4338,9.5883,8.5,9.5H6.5664z',
655
+ 'bar': 'M7.5,1c-2,0-7,0.25-6.5,0.75L7,8v4 c0,1-3,0.5-3,2h7c0-1.5-3-1-3-2V8l6-6.25C14.5,1.25,9.5,1,7.5,1z M7.5,2c2.5,0,4.75,0.25,4.75,0.25L11.5,3h-8L2.75,2.25 C2.75,2.25,5,2,7.5,2z',
656
+ 'beer': 'M12,5V2c0,0-1-1-4.5-1S3,2,3,2v3c0.0288,1.3915,0.3706,2.7586,1,4c0.6255,1.4348,0.6255,3.0652,0,4.5c0,0,0,1,3.5,1 s3.5-1,3.5-1c-0.6255-1.4348-0.6255-3.0652,0-4.5C11.6294,7.7586,11.9712,6.3915,12,5z M7.5,13.5 c-0.7966,0.035-1.5937-0.0596-2.36-0.28c0.203-0.7224,0.304-1.4696,0.3-2.22h4.12c-0.004,0.7504,0.097,1.4976,0.3,2.22 C9.0937,13.4404,8.2966,13.535,7.5,13.5z M7.5,5C6.3136,5.0299,5.1306,4.8609,4,4.5v-2C5.131,2.1411,6.3137,1.9722,7.5,2 C8.6863,1.9722,9.869,2.1411,11,2.5v2C9.8694,4.8609,8.6864,5.0299,7.5,5z',
657
+ 'cafe': 'M12,5h-2V3H2v4c0.0133,2.2091,1.8149,3.9891,4.024,3.9758C7.4345,10.9673,8.7362,10.2166,9.45,9H12c1.1046,0,2-0.8954,2-2 S13.1046,5,12,5z M12,8H9.86C9.9487,7.6739,9.9958,7.3379,10,7V6h2c0.5523,0,1,0.4477,1,1S12.5523,8,12,8z M10,12.5 c0,0.2761-0.2239,0.5-0.5,0.5h-7C2.2239,13,2,12.7761,2,12.5S2.2239,12,2.5,12h7C9.7761,12,10,12.2239,10,12.5z',
658
+ 'cinema': 'M14,7.5v2c0,0.2761-0.2239,0.5-0.5,0.5S13,9.7761,13,9.5c0,0,0.06-0.5-1-0.5h-1v2.5c0,0.2761-0.2239,0.5-0.5,0.5h-8 C2.2239,12,2,11.7761,2,11.5v-4C2,7.2239,2.2239,7,2.5,7h8C10.7761,7,11,7.2239,11,7.5V8h1c1.06,0,1-0.5,1-0.5 C13,7.2239,13.2239,7,13.5,7S14,7.2239,14,7.5z M4,3C2.8954,3,2,3.8954,2,5s0.8954,2,2,2s2-0.8954,2-2S5.1046,3,4,3z M4,6 C3.4477,6,3,5.5523,3,5s0.4477-1,1-1s1,0.4477,1,1S4.5523,6,4,6z M8.5,2C7.1193,2,6,3.1193,6,4.5S7.1193,7,8.5,7S11,5.8807,11,4.5 S9.8807,2,8.5,2z M8.5,6C7.6716,6,7,5.3284,7,4.5S7.6716,3,8.5,3S10,3.6716,10,4.5S9.3284,6,8.5,6z',
659
+ 'convenience': 'M2.9982,3.0687s.1875-1.062,1-1.062H5.0043V1.7276a.5.5,0,0,1,.5-.5H9.4882a.5.5,0,0,1,.5.5v.2791h1.01a.8324.8324,0,0,1,.6373.2937,1.7589,1.7589,0,0,1,.3627.7683s.3108,1.5719.5921,2.9181h-.938L11,3,3.982,2.9956,3.3336,5.9868H2.382Zm11,4.9144a1,1,0,0,1-.9992,1l-1.0008,3.973s-.1875,1.0417-1,1.0417h-7c-.8125,0-1-1.0417-1-1.0417L1.9822,8.98a.9985.9985,0,0,1,.016-1.9968h11A1,1,0,0,1,13.9982,7.9831ZM5.0327,12.13,5.03,11.0051,4.6392,9.4686a.5373.5373,0,1,0-1.0649.1445L3.78,11.0075l.1874,1.2672A.5373.5373,0,1,0,5.0327,12.13Zm2-2.5967A.5332.5332,0,0,0,6.4994,9l-.0039.0008L6.4886,9a.4992.4992,0,0,0-.3575.1509.529.529,0,0,0-.1648.3828v2.68a.5332.5332,0,1,0,1.0663,0Zm1.998,0A.5314.5314,0,0,0,8.694,9.04a.4992.4992,0,0,0-.393,0,.5312.5312,0,0,0-.3366.4934v2.68a.5331.5331,0,1,0,1.0662,0Zm2.3857.08a.5373.5373,0,1,0-1.0649-.1445L9.96,10.9938,9.9579,12.13a.5373.5373,0,1,0,1.0649.1446Z',
660
+ 'fast-food': 'M14,8c0,0.5523-0.4477,1-1,1H2C1.4477,9,1,8.5523,1,8s0.4477-1,1-1h11C13.5523,7,14,7.4477,14,8z M3.5,10H2 c0,1.6569,1.3431,3,3,3h5c1.6569,0,3-1.3431,3-3H3.5z M3,6H2V4c0-1.1046,0.8954-2,2-2h7c1.1046,0,2,0.8954,2,2v2H3z M11,4.5 C11,4.7761,11.2239,5,11.5,5S12,4.7761,12,4.5S11.7761,4,11.5,4S11,4.2239,11,4.5z M9,3.5C9,3.7761,9.2239,4,9.5,4S10,3.7761,10,3.5 S9.7761,3,9.5,3S9,3.2239,9,3.5z M7,4.5C7,4.7761,7.2239,5,7.5,5S8,4.7761,8,4.5S7.7761,4,7.5,4S7,4.2239,7,4.5z M5,3.5 C5,3.7761,5.2239,4,5.5,4S6,3.7761,6,3.5S5.7761,3,5.5,3S5,3.2239,5,3.5z M3,4.5C3,4.7761,3.2239,5,3.5,5S4,4.7761,4,4.5 S3.7761,4,3.5,4S3,4.2239,3,4.5z',
661
+ 'fuel': 'm14 6v5.5c0 .2761-.2239.5-.5.5s-.5-.2239-.5-.5v-2c0-.8284-.6716-1.5-1.5-1.5h-1.5v-6c0-.5523-.4477-1-1-1h-6c-.5523 0-1 .4477-1 1v11c0 .5523.4477 1 1 1h6c.5523 0 1-.4477 1-1v-4h1.5c.2761 0 .5.2239.5.5v2c0 .8284.6716 1.5 1.5 1.5s1.5-.6716 1.5-1.5v-6.5c0-.5523-.4477-1-1-1v-1.51c-.0054-.2722-.2277-.4901-.5-.49-.2816.0047-.5062.2367-.5015.5184.0002.0105.0007.0211.0015.0316v2.45c0 .5523.4477 1 1 1s1-.4477 1-1-.4477-1-1-1zm-5 .5c0 .2761-.2239.5-.5.5h-5c-.2761 0-.5-.2239-.5-.5v-3c0-.2761.2239-.5.5-.5h5c.2761 0 .5.2239.5.5z',
662
+ 'grocery': 'M 13.199219 1.5 C 13.199219 1.5 11.808806 1.4588 11.253906 2 C 10.720406 2.5202 10.5 2.9177 10.5 4 L 1.1992188 4 L 2.59375 8.8144531 C 2.59725 8.8217531 2.6036219 8.8287375 2.6074219 8.8359375 C 2.8418219 9.4932375 3.4545469 9.9666406 4.1855469 9.9941406 C 4.1885469 9.9954406 4.1992187 10 4.1992188 10 L 10.699219 10 L 10.699219 10.199219 C 10.699219 10.199219 10.7 10.500391 10.5 10.900391 C 10.3 11.300391 10.200391 11.5 9.4003906 11.5 L 2.9003906 11.5 C 1.9003906 11.5 1.9003906 13 2.9003906 13 L 4.0996094 13 L 4.1992188 13 L 9.0996094 13 L 9.1992188 13 L 9.3007812 13 C 10.500781 13 11.399219 12.299609 11.699219 11.599609 C 11.999219 10.899609 12 10.300781 12 10.300781 L 12 10 L 12 4 C 12 3.4764 12.228619 3 12.699219 3 L 13.25 3 C 13.6642 3 14 2.6642 14 2.25 C 14 1.8358 13.6642 1.5 13.25 1.5 L 13.199219 1.5 z M 9.1992188 13 C 8.5992188 13 8.1992188 13.4 8.1992188 14 C 8.1992188 14.6 8.5992187 15 9.1992188 15 C 9.7992187 15 10.199219 14.6 10.199219 14 C 10.199219 13.4 9.7992188 13 9.1992188 13 z M 4.1992188 13 C 3.5992188 13 3.1992188 13.4 3.1992188 14 C 3.1992188 14.6 3.5992187 15 4.1992188 15 C 4.7992188 15 5.1992188 14.6 5.1992188 14 C 5.1992188 13.4 4.7992187 13 4.1992188 13 z',
663
+ 'hospital': 'M7,1C6.4,1,6,1.4,6,2v4H2C1.4,6,1,6.4,1,7v1 c0,0.6,0.4,1,1,1h4v4c0,0.6,0.4,1,1,1h1c0.6,0,1-0.4,1-1V9h4c0.6,0,1-0.4,1-1V7c0-0.6-0.4-1-1-1H9V2c0-0.6-0.4-1-1-1H7z',
664
+ 'ice-cream': 'M5.44,8.17a3.473,3.473,0,0,0,2-.63A3.5,3.5,0,0,0,9,8.14h.44L8,13.7a.5.5,0,0,1-.92,0Zm6-3.473a2,2,0,0,1-4,0h0a2,2,0,1,1-2-2h.12a2,2,0,1,1,3.75,0h.13A2,2,0,0,1,11.439,4.7Z',
665
+ 'lodging': 'M0.5,2.5C0.2,2.5,0,2.7,0,3v7.5v2C0,12.8,0.2,13,0.5,13S1,12.8,1,12.5V11h13v1.5 c0,0.3,0.2,0.5,0.5,0.5s0.5-0.2,0.5-0.5v-2c0-0.3-0.2-0.5-0.5-0.5H1V3C1,2.7,0.8,2.5,0.5,2.5z M3.5,3C2.7,3,2,3.7,2,4.5l0,0 C2,5.3,2.7,6,3.5,6l0,0C4.3,6,5,5.3,5,4.5l0,0C5,3.7,4.3,3,3.5,3L3.5,3z M7,4C5.5,4,5.5,5.5,5.5,5.5V7h-3C2.2,7,2,7.2,2,7.5v1 C2,8.8,2.2,9,2.5,9H6h9V6.5C15,4,12.5,4,12.5,4H7z',
666
+ 'marker': 'M7.5 1C5.42312 1 3 2.2883 3 5.56759C3 7.79276 6.46156 12.7117 7.5 14C8.42309 12.7117 12 7.90993 12 5.56759C12 2.2883 9.57688 1 7.5 1Z',
667
+ 'museum': 'M7.5,0L1,3.5V4h13V3.5L7.5,0z M2,5v5l-1,1.6V13h13v-1.4L13,10V5H2z M4,6h1v5.5H4V6z M7,6h1v5.5H7V6z M10,6h1 v5.5h-1V6z',
668
+ 'park': 'M14,5.75c0.0113-0.6863-0.3798-1.3159-1-1.61C12.9475,3.4906,12.4014,2.9926,11.75,3 c-0.0988,0.0079-0.1962,0.0281-0.29,0.06c-0.0607-0.66-0.6449-1.1458-1.3048-1.0851C9.8965,1.9987,9.6526,2.1058,9.46,2.28l0,0 c0-0.6904-0.5596-1.25-1.25-1.25S6.96,1.5896,6.96,2.28C6.96,2.28,7,2.3,7,2.33C6.4886,1.8913,5.7184,1.9503,5.2797,2.4618 C5.1316,2.6345,5.0347,2.8451,5,3.07C4.8417,3.0195,4.6761,2.9959,4.51,3C3.6816,2.9931,3.0044,3.659,2.9975,4.4874 C2.9958,4.6872,3.0341,4.8852,3.11,5.07C2.3175,5.2915,1.8546,6.1136,2.0761,6.9061C2.2163,7.4078,2.6083,7.7998,3.11,7.94 c0.2533,0.7829,1.0934,1.2123,1.8763,0.959C5.5216,8.7258,5.9137,8.2659,6,7.71C6.183,7.8691,6.4093,7.9701,6.65,8v5L5,14h5l-1.6-1 v-2c0.7381-0.8915,1.6915-1.5799,2.77-2c0.8012,0.1879,1.603-0.3092,1.7909-1.1103C12.9893,7.7686,13.0025,7.6444,13,7.52 c0.0029-0.0533,0.0029-0.1067,0-0.16C13.6202,7.0659,14.0113,6.4363,14,5.75z M8.4,10.26V6.82C8.6703,7.3007,9.1785,7.5987,9.73,7.6 h0.28c0.0156,0.4391,0.2242,0.849,0.57,1.12C9.7643,9.094,9.0251,9.6162,8.4,10.26z',
669
+ 'parking': 'M4 2V13H6V9H8.5C10.433 9 12 7.433 12 5.5C12 3.567 10.433 2 8.5 2H4ZM6 7V4H8.5C9.32843 4 10 4.67157 10 5.5C10 6.32843 9.32843 7 8.5 7H6Z',
670
+ 'pharmacy': 'M9.5,4l1.07-1.54c0.0599,0.0046,0.1201,0.0046,0.18,0c0.6904-0.0004,1.2497-0.5603,1.2494-1.2506 C11.999,0.519,11.4391-0.0404,10.7487-0.04C10.0584-0.0396,9.499,0.5203,9.4994,1.2106c0,0.0131,0.0002,0.0262,0.0006,0.0394 c0,0,0,0.07,0,0.1L7,4H9.5z M12,6V5H3v1l1.5,3.5L3,13v1h9v-1l-1-3.5L12,6z M10,10H8v2H7v-2H5V9h2V7h1v2h2V10z',
671
+ 'restaurant-pizza': 'M1.393 7.931.025 7.315A14.05 14.05 0 0 1 7.764-.001l.027.07.511 1.331-.003.001a12.613 12.613 0 0 0-6.906 6.53Zm10.963 4.96L2.42 8.414a11.604 11.604 0 0 1 6.354-6.009l.003-.001.817 2.122c-.028-.002-.052-.016-.081-.016a1.062 1.062 0 1 0 .764 1.793l2.413 6.273a.249.249 0 0 1-.334.316ZM7.426 7.52a.925.925 0 1 0-1.849 0 .925.925 0 0 0 1.849 0Zm3.017 2.03a.925.925 0 1 0-.07.353.924.924 0 0 0 .07-.354Z',
672
+ 'restaurant': 'M3.5,0l-1,5.5c-0.1464,0.805,1.7815,1.181,1.75,2L4,14c-0.0384,0.9993,1,1,1,1s1.0384-0.0007,1-1L5.75,7.5 c-0.0314-0.8176,1.7334-1.1808,1.75-2L6.5,0H6l0.25,4L5.5,4.5L5.25,0h-0.5L4.5,4.5L3.75,4L4,0H3.5z M12,0 c-0.7364,0-1.9642,0.6549-2.4551,1.6367C9.1358,2.3731,9,4.0182,9,5v2.5c0,0.8182,1.0909,1,1.5,1L10,14c-0.0905,0.9959,1,1,1,1 s1,0,1-1V0z',
673
+ 'shop': 'm13.33 5h-1.83l-.39-2.33c-.1601-.7182-.7017-1.2905-1.41-1.49-.3493-.1124-.7131-.173-1.08-.18h-2.24c-.3669.007-.7307.0676-1.08.18-.7083.1995-1.2499.7718-1.41 1.49l-.39 2.33h-1.83c-.2761-.0017-.5013.2208-.503.497-.0003.0519.0074.1035.023.153l1.88 6.3c.1964.6246.7753 1.0496 1.43 1.05h6c.651-.0047 1.2247-.4289 1.42-1.05l1.88-6.3c.0829-.2634-.0635-.5441-.3269-.627-.0463-.0146-.0945-.0223-.1431-.023zm-8.81 0 .36-2.17c.0807-.3625.3736-.6395.74-.7.2463-.0776.5019-.1213.76-.13h2.24c.2614.0078.5205.0515.77.13.3664.0605.6593.3375.74.7l.35 2.17h-6z',
674
+ 'theatre': 'M2,1c0,0-1,0-1,1v5.1582C1,8.8885,1.354,11,4.5,11H5V8L2.5,9c0,0,0-2.5,2.5-2.5V5 c0-0.7078,0.0868-1.3209,0.5-1.7754C5.8815,2.805,6.5046,1.9674,8.1562,2.7539L9,3.3027V2c0,0,0-1-1-1C7.2922,1,6.0224,2,5,2 S2.7865,1,2,1z M3,3c0.5523,0,1,0.4477,1,1S3.5523,5,3,5S2,4.5523,2,4S2.4477,3,3,3z M7,4c0,0-1,0-1,1v5c0,2,1,4,4,4s4-2,4-4V5 c0-1-1-1-1-1c-0.7078,0-1.9776,1-3,1S7.7865,4,7,4z M8,6c0.5523,0,1,0.4477,1,1S8.5523,8,8,8S7,7.5523,7,7S7.4477,6,8,6z M12,6 c0.5523,0,1,0.4477,1,1s-0.4477,1-1,1s-1-0.4477-1-1S11.4477,6,12,6z M7.5,10H10h2.5c0,0,0,2.5-2.5,2.5S7.5,10,7.5,10z'
675
+ };
676
+ // Category words (Search Box poi_category, or a caller's category) to
677
+ // Maki names, checked in order, so "pizza restaurant" is a pizza.
678
+ var CATEGORY_ICONS = [
679
+ ['pizza', 'restaurant-pizza'], ['coffee', 'cafe'], ['cafe', 'cafe'],
680
+ ['café', 'cafe'], ['bakery', 'bakery'], ['ice cream', 'ice-cream'],
681
+ ['brewery', 'beer'], ['beer', 'beer'], ['pub', 'beer'],
682
+ ['bar', 'bar'], ['nightclub', 'bar'], ['wine', 'bar'],
683
+ ['fast food', 'fast-food'], ['burger', 'fast-food'],
684
+ ['restaurant', 'restaurant'], ['food', 'restaurant'], ['diner', 'restaurant'],
685
+ ['hotel', 'lodging'], ['motel', 'lodging'], ['hostel', 'lodging'], ['lodging', 'lodging'],
686
+ ['grocery', 'grocery'], ['supermarket', 'grocery'], ['convenience', 'convenience'],
687
+ ['gas', 'fuel'], ['fuel', 'fuel'], ['parking', 'parking'],
688
+ ['hospital', 'hospital'], ['clinic', 'hospital'], ['pharmacy', 'pharmacy'],
689
+ ['park', 'park'], ['garden', 'park'], ['museum', 'museum'], ['gallery', 'museum'],
690
+ ['theater', 'theatre'], ['theatre', 'theatre'], ['cinema', 'cinema'], ['movie', 'cinema'],
691
+ ['bank', 'bank'], ['atm', 'bank'],
692
+ ['shop', 'shop'], ['store', 'shop'], ['boutique', 'shop'],
693
+ ['attraction', 'attraction'], ['landmark', 'attraction']
694
+ ];
695
+
696
+ // The Maki name for a marker or card: its own maki when we have that
697
+ // icon, else one matched from its category, else the plain marker.
698
+ function placeIcon(maki, category) {
699
+ if (typeof maki === 'string' && MAKI.hasOwnProperty(maki)) return maki;
700
+ if (typeof category === 'string') {
701
+ var words = ' ' + category.toLowerCase().split(/[^a-zà-ÿ]+/).join(' ') + ' ';
702
+ for (var i = 0; i < CATEGORY_ICONS.length; i++) {
703
+ if (words.indexOf(' ' + CATEGORY_ICONS[i][0] + ' ') !== -1) return CATEGORY_ICONS[i][1];
704
+ }
705
+ }
706
+ return 'marker';
707
+ }
708
+ window.__placeIcon = placeIcon;
709
+
710
+ // Only ever called with a key of MAKI, so the markup is all ours.
711
+ function iconSvg(name) {
712
+ var d = MAKI[MAKI.hasOwnProperty(name) ? name : 'marker'];
713
+ return '<svg viewBox="0 0 15 15" aria-hidden="true"><path fill="currentColor" d="' + d + '"/></svg>';
714
+ }
715
+
716
+ function buildPlaceElement(icon, color) {
717
+ var el = document.createElement('div');
718
+ el.className = 'marker-badge place';
719
+ // A caller's color (gray for "other results", say) tints the icon.
720
+ if (color) el.style.color = color;
721
+ el.innerHTML = iconSvg(icon);
722
+ return el;
723
+ }
724
+
725
+ // Dark or white, whichever reads better on the badge color (WCAG
726
+ // contrast), so numbers stay legible on orange, green or gray pins.
727
+ var BADGE_DARK_TEXT = '#1a0f04';
728
+ function badgeTextColor(color) {
729
+ var hex = typeof color === 'string' ? color.replace('#', '') : '';
730
+ if (hex.length === 3) hex = hex.split('').map(function(c) { return c + c; }).join('');
731
+ if (!/^[0-9a-f]{6}/i.test(hex)) return '#fff';
732
+ var lum = [0, 2, 4].map(function(i) {
733
+ var c = parseInt(hex.slice(i, i + 2), 16) / 255;
734
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
735
+ });
736
+ var l = 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2];
737
+ // Against white (L=1) vs. against the dark text (L about 0.0055).
738
+ return (1.05 / (l + 0.05)) >= ((l + 0.05) / 0.0555) ? '#fff' : BADGE_DARK_TEXT;
739
+ }
740
+ window.__badgeTextColor = badgeTextColor;
741
+
468
742
  function buildBadgeElement(label, color) {
469
743
  var el = document.createElement('div');
470
744
  el.className = 'marker-badge';
471
745
  el.style.background = color;
746
+ el.style.color = badgeTextColor(color);
472
747
  el.textContent = label;
473
748
  return el;
474
749
  }
@@ -480,7 +755,24 @@ ${initialDataScript}
480
755
  if (!layer || !layer.id || !layer.data) return;
481
756
  map.addSource(layer.id, { type: 'geojson', data: layer.data });
482
757
  trackedSourceIds.push(layer.id);
758
+ map.addLayer(layerDefinition(layer));
759
+ trackedLayerIds.push(layer.id);
760
+ drawnLayers.push({
761
+ id: layer.id, type: layer.type, data: layer.data,
762
+ paint: layer.paint, layout: layer.layout, slot: layer.slot
763
+ });
483
764
 
765
+ var feature = layer.data;
766
+ if (feature.type === 'FeatureCollection' && Array.isArray(feature.features)) {
767
+ feature.features.forEach(function(f) {
768
+ if (f && f.geometry) bbox.extend(f.geometry.coordinates);
769
+ });
770
+ } else if (feature.geometry) {
771
+ bbox.extend(feature.geometry.coordinates);
772
+ }
773
+ }
774
+
775
+ function layerDefinition(layer) {
484
776
  var def = {
485
777
  id: layer.id,
486
778
  type: layer.type,
@@ -489,17 +781,176 @@ ${initialDataScript}
489
781
  if (layer.paint) def.paint = layer.paint;
490
782
  if (layer.layout) def.layout = layer.layout;
491
783
  if (layer.slot) def.slot = layer.slot;
492
- map.addLayer(def);
493
- trackedLayerIds.push(layer.id);
784
+ return def;
785
+ }
494
786
 
495
- var feature = layer.data;
496
- if (feature.type === 'FeatureCollection' && Array.isArray(feature.features)) {
497
- feature.features.forEach(function(f) {
498
- if (f && f.geometry) bbox.extend(f.geometry.coordinates);
787
+ // --- Satellite / Traffic toggles -----------------------------------------
788
+ function addTrafficLayer() {
789
+ if (!map.getSource(TRAFFIC_ID)) {
790
+ map.addSource(TRAFFIC_ID, {
791
+ type: 'vector',
792
+ url: 'mapbox://mapbox.mapbox-traffic-v1'
499
793
  });
500
- } else if (feature.geometry) {
501
- bbox.extend(feature.geometry.coordinates);
502
794
  }
795
+ if (map.getLayer(TRAFFIC_ID)) return;
796
+ var colors = ['match', ['get', 'congestion']];
797
+ TRAFFIC_LEVELS.forEach(function(level) { colors.push(level.value, level.color); });
798
+ colors.push('rgba(0,0,0,0)');
799
+ map.addLayer({
800
+ id: TRAFFIC_ID,
801
+ type: 'line',
802
+ source: TRAFFIC_ID,
803
+ 'source-layer': 'traffic',
804
+ // Above land and water, below roads' labels and anything we draw.
805
+ slot: 'middle',
806
+ // Light traffic on every side street turns the whole map green, so
807
+ // show it only on main roads; any slowdown shows on every road.
808
+ filter: ['any',
809
+ ['!=', ['get', 'congestion'], 'low'],
810
+ ['match', ['get', 'class'], TRAFFIC_MAIN_ROADS, true, false]
811
+ ],
812
+ layout: { 'line-join': 'round', 'line-cap': 'round' },
813
+ paint: {
814
+ 'line-color': colors,
815
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1, 15, 2, 18, 4],
816
+ // Each direction is its own line; shift it to its side of the road.
817
+ 'line-offset': ['interpolate', ['linear'], ['zoom'], 12, 0.5, 15, 1.5, 18, 3],
818
+ 'line-opacity': 0.9
819
+ }
820
+ });
821
+ }
822
+
823
+ function removeTrafficLayer() {
824
+ if (map.getLayer(TRAFFIC_ID)) map.removeLayer(TRAFFIC_ID);
825
+ if (map.getSource(TRAFFIC_ID)) map.removeSource(TRAFFIC_ID);
826
+ }
827
+
828
+ function setTraffic(on) {
829
+ trafficOn = on;
830
+ if (map && mapLoaded) {
831
+ if (on) addTrafficLayer();
832
+ else removeTrafficLayer();
833
+ }
834
+ renderLegend();
835
+ syncToggleButtons();
836
+ }
837
+
838
+ // Switches the base style, then runs afterLoad once the new style is up.
839
+ function switchBaseStyle(styleId, afterLoad) {
840
+ currentBaseStyleId = styleId;
841
+ syncToggleButtons();
842
+ map.setStyle('mapbox://styles/mapbox/' + styleId);
843
+ map.once('style.load', afterLoad);
844
+ }
845
+
846
+ // After a style switch from the Satellite button: put back what setStyle
847
+ // dropped, without refitting the camera the user may have moved.
848
+ function restoreOverlays() {
849
+ applyBaseMapConfig(currentBaseMapConfig);
850
+ drawnLayers.forEach(function(layer) {
851
+ if (!map.getSource(layer.id)) {
852
+ map.addSource(layer.id, { type: 'geojson', data: layer.data });
853
+ }
854
+ if (!map.getLayer(layer.id)) map.addLayer(layerDefinition(layer));
855
+ });
856
+ if (trafficOn) addTrafficLayer();
857
+ }
858
+
859
+ function toggleSatellite() {
860
+ if (!map || !mapLoaded) return;
861
+ switchBaseStyle(
862
+ currentBaseStyleId === 'standard-satellite' ? 'standard' : 'standard-satellite',
863
+ restoreOverlays
864
+ );
865
+ }
866
+
867
+ // A GL JS control (onAdd returns its element). Built by a returned object
868
+ // rather than prototype methods because initMap() runs before the rest
869
+ // of this script, and addControl calls onAdd right away.
870
+ function LayerToggleControl() {
871
+ return { onAdd: buildLayerToggles, onRemove: function() {} };
872
+ }
873
+
874
+ function buildLayerToggles() {
875
+ var container = document.createElement('div');
876
+ container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group layer-toggles';
877
+ satelliteButton = buildToggleButton('Satellite view', TOGGLE_ICONS.satellite, toggleSatellite);
878
+ trafficButton = buildToggleButton('Live traffic', TOGGLE_ICONS.traffic, function() {
879
+ setTraffic(!trafficOn);
880
+ });
881
+ container.appendChild(satelliteButton);
882
+ container.appendChild(trafficButton);
883
+ syncToggleButtons();
884
+ return container;
885
+ }
886
+
887
+ function buildToggleButton(label, icon, onClick) {
888
+ var button = document.createElement('button');
889
+ button.type = 'button';
890
+ button.title = label;
891
+ if (typeof button.setAttribute === 'function') button.setAttribute('aria-label', label);
892
+ button.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" ' +
893
+ 'fill="currentColor" aria-hidden="true">' + icon + '</svg>';
894
+ button.addEventListener('click', onClick);
895
+ return button;
896
+ }
897
+
898
+ function syncToggleButtons() {
899
+ setPressed(satelliteButton, currentBaseStyleId === 'standard-satellite');
900
+ setPressed(trafficButton, trafficOn);
901
+ }
902
+
903
+ function setPressed(button, pressed) {
904
+ if (!button) return;
905
+ button.className = pressed ? 'active' : '';
906
+ if (typeof button.setAttribute === 'function') {
907
+ button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
908
+ }
909
+ }
910
+
911
+ // The summary chip covers GL JS's top-left control corner, so move the
912
+ // zoom and layer buttons below it while it's shown.
913
+ function placeTopLeftControls() {
914
+ if (!map || typeof map.getContainer !== 'function') return;
915
+ var container = map.getContainer();
916
+ var corner = container && typeof container.querySelector === 'function'
917
+ ? container.querySelector('.mapboxgl-ctrl-top-left')
918
+ : null;
919
+ if (!corner) return;
920
+ corner.style.top = summaryEl.style.display === 'block'
921
+ ? (summaryEl.offsetHeight + 8) + 'px'
922
+ : '';
923
+ }
924
+
925
+ // The payload's legend rows, and below them the traffic scale while
926
+ // traffic is on.
927
+ function renderLegend() {
928
+ renderTrafficLegend();
929
+ var rows = payloadLegend;
930
+ if (rows.length === 0) {
931
+ legendEl.style.display = 'none';
932
+ return;
933
+ }
934
+ legendEl.innerHTML = rows.map(function(row) {
935
+ var op = (typeof row.opacity === 'number') ? row.opacity : 1;
936
+ return '<div class="row"><span class="swatch" style="background:' +
937
+ escapeAttr(row.color) + ';opacity:' + op + '"></span>' +
938
+ escapeText(row.label) + '</div>';
939
+ }).join('');
940
+ legendEl.style.display = 'block';
941
+ }
942
+
943
+ function renderTrafficLegend() {
944
+ if (!trafficOn) {
945
+ trafficLegendEl.style.display = 'none';
946
+ return;
947
+ }
948
+ trafficLegendEl.innerHTML = '<span>Fast</span><span class="bar">' +
949
+ TRAFFIC_LEVELS.map(function(level) {
950
+ return '<span title="' + level.label + '" style="background:' +
951
+ level.color + '"></span>';
952
+ }).join('') + '</span><span>Slow</span>';
953
+ trafficLegendEl.style.display = 'flex';
503
954
  }
504
955
 
505
956
  function addOneMarker(m, bbox) {
@@ -510,38 +961,58 @@ ${initialDataScript}
510
961
  var color = m.color ||
511
962
  (STYLE_DEFAULTS[style] && STYLE_DEFAULTS[style].color) || '#3b82f6';
512
963
  var marker;
513
- if (style === 'numbered' || style === 'start' || style === 'end') {
964
+ if (style === 'place') {
965
+ marker = new mapboxgl.Marker({ element: buildPlaceElement(placeIcon(m.maki, m.category), m.color) });
966
+ } else if (style === 'numbered' || style === 'start' || style === 'end') {
514
967
  var label = m.label || (style === 'start' ? 'S' : style === 'end' ? 'E' : '');
515
968
  marker = new mapboxgl.Marker({ element: buildBadgeElement(label, color) });
516
969
  } else {
517
970
  marker = new mapboxgl.Marker({ color: color });
518
971
  }
519
972
  marker.setLngLat(m.coordinates);
520
- if (m.popup) marker.setPopup(new mapboxgl.Popup().setText(m.popup));
973
+ // A place with an id gets a carousel card instead of a popup: clicking
974
+ // its pin selects the card (see selectPlace).
975
+ if (m.popup && !m.id) marker.setPopup(new mapboxgl.Popup().setText(m.popup));
521
976
  marker.addTo(map);
522
977
  trackedMarkers.push(marker);
523
978
  // Tagged by buildSearchMiniPayload with the result's mapbox_id so the
524
- // side panel (click-to-flyTo) and Place Details enrichment can look the
525
- // marker back up later without walking trackedMarkers.
526
- if (m.id) markersById[m.id] = { marker: marker, popupText: m.popup || '' };
979
+ // carousel and Place Details enrichment can look the marker back up
980
+ // later without walking trackedMarkers.
981
+ if (m.id) {
982
+ var badgeEl = typeof marker.getElement === 'function' ? marker.getElement() : null;
983
+ markersById[m.id] = { marker: marker, el: badgeEl, color: color };
984
+ if (badgeEl && m.name) {
985
+ var nameEl = document.createElement('span');
986
+ nameEl.className = 'marker-name';
987
+ nameEl.textContent = m.name;
988
+ badgeEl.appendChild(nameEl);
989
+ }
990
+ if (badgeEl && typeof badgeEl.addEventListener === 'function') {
991
+ var placeId = m.id;
992
+ badgeEl.addEventListener('click', function(e) {
993
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
994
+ selectPlace(placeId, { scrollCard: true, panTo: true });
995
+ });
996
+ }
997
+ }
527
998
  bbox.extend(m.coordinates);
528
999
  }
529
1000
 
530
- // Guarantees a marker that opts into the side panel (has .id) also gets
531
- // a visible number on the map matching its panel row, unless the caller
1001
+ // Guarantees a marker that opts into the carousel (has .id) also gets
1002
+ // a visible number on the map matching its card, unless the caller
532
1003
  // already gave it an explicit label. Without this, a caller that builds
533
1004
  // plain unlabeled pins for POI markers (observed live: Claude did this
534
1005
  // for inline markers, leaving the map showing generic blue pins with no
535
- // way to tell which panel row is which) breaks the whole point of the
536
- // panel numbering. Mutates markers in place, so it must run before
1006
+ // way to tell which card is which) breaks the whole point of the
1007
+ // numbering. Mutates markers in place, so it must run before
537
1008
  // addOneMarker draws them.
538
1009
  //
539
1010
  // Numbering runs across the whole render, not per call: a render can
540
1011
  // draw inline markers and then merge in self-fetched search results, and
541
1012
  // each set arrives numbered from 1. The first set keeps the caller's
542
1013
  // numbers when they're unique (a visit order, say); a later set, or one
543
- // with repeated numbers, is renumbered to continue the panel's count.
544
- // Letter labels ("A") are kept as they are. A place already in the panel
1014
+ // with repeated numbers, is renumbered to continue the carousel's count.
1015
+ // Letter labels ("A") are kept as they are. A place already in the carousel
545
1016
  // this render is dropped rather than drawn and listed twice. Returns the
546
1017
  // markers to draw.
547
1018
  function applyPanelNumbering(markers) {
@@ -568,12 +1039,17 @@ ${initialDataScript}
568
1039
  fresh.forEach(function(m) {
569
1040
  if (!m || !m.id) return;
570
1041
  if (m.label && (keep || !isWholeNumber(m.label))) return;
1042
+ // A place with no number of its own gets an icon pin, not a number:
1043
+ // the selected pin and card light up together, which is enough to
1044
+ // tell them apart.
1045
+ if (!m.label && m.style !== 'start' && m.style !== 'end') {
1046
+ m.style = 'place';
1047
+ return;
1048
+ }
1049
+ // Otherwise a numbered pin, or a start/end pin, takes the next number.
571
1050
  panelCount += 1;
572
1051
  var n = String(panelCount);
573
- if (!m.label) {
574
- if (m.style !== 'start' && m.style !== 'end') m.style = 'numbered';
575
- if (!m.color) m.color = '#f97316';
576
- } else if (typeof m.popup === 'string' && m.popup.indexOf(m.label + '. ') === 0) {
1052
+ if (m.label && typeof m.popup === 'string' && m.popup.indexOf(m.label + '. ') === 0) {
577
1053
  // Search popups start with the old number ("3. Name").
578
1054
  m.popup = n + '. ' + m.popup.slice(String(m.label).length + 2);
579
1055
  }
@@ -588,26 +1064,33 @@ ${initialDataScript}
588
1064
  return n > 0 && String(n) === String(label);
589
1065
  }
590
1066
 
591
- // Bottom padding that keeps fitted data out from under the legend
592
- // (bottom-left), unless the map is too short to spare the room.
1067
+ // Bottom padding that keeps fitted data out from under the carousel and
1068
+ // the legends above it, unless the map is too short to spare the room.
593
1069
  function bottomPadding() {
594
- if (legendEl.style.display !== 'block') return 50;
595
- var bottom = (legendEl.offsetHeight || 0) + 24;
1070
+ var bottom = 0;
1071
+ if (carouselEl.style.display === 'block') bottom = carouselTop() + 12;
1072
+ if (legendEl.style.display === 'block' ||
1073
+ trafficLegendEl.style.display === 'flex') {
1074
+ bottom = Math.max(bottom, legendTop() + 12);
1075
+ }
596
1076
  var el = document.getElementById('map');
597
1077
  var height = (el && el.clientHeight) || window.innerHeight || 0;
598
- if (bottom <= 50 || (height && height - bottom - 70 < 160)) return 50;
1078
+ if (bottom <= 50 || (height && height - bottom - 70 < 120)) return 50;
599
1079
  return bottom;
600
1080
  }
601
1081
 
602
- // Right padding that keeps fitted data out from under an open panel. On
603
- // a narrow map there's no room beside it, so fit as if it weren't there
604
- // (the panel starts collapsed there anyway; see appendPanelRows).
605
- function rightPadding() {
606
- if (panelEl.style.display !== 'flex' || panelCollapsed) return 30;
607
- var right = (panelEl.offsetWidth || 280) + 24;
608
- var mapWidth = mapContainerWidth();
609
- if (mapWidth && mapWidth - right < 240) return 30;
610
- return right;
1082
+ // Distance from the bottom of the map to the top of the carousel. 36px
1083
+ // keeps it clear of the Mapbox logo and attribution.
1084
+ var CAROUSEL_BOTTOM = 36;
1085
+ function carouselTop() {
1086
+ return CAROUSEL_BOTTOM + (carouselEl.offsetHeight || 100);
1087
+ }
1088
+
1089
+ // The legends stack sits 40px up (above the Mapbox logo) until the
1090
+ // carousel pushes it higher.
1091
+ function legendTop() {
1092
+ var bottom = parseInt(legendsEl.style.bottom, 10) || 40;
1093
+ return bottom + (legendsEl.offsetHeight || 0);
611
1094
  }
612
1095
 
613
1096
  function mapContainerWidth() {
@@ -621,7 +1104,7 @@ ${initialDataScript}
621
1104
  setTimeout(function() {
622
1105
  map.resize();
623
1106
  map.fitBounds(bounds, {
624
- padding: { top: 70, bottom: bottomPadding(), left: 30, right: rightPadding() },
1107
+ padding: { top: 70, bottom: bottomPadding(), left: 30, right: 30 },
625
1108
  duration: 600
626
1109
  });
627
1110
  }, 60);
@@ -642,14 +1125,16 @@ ${initialDataScript}
642
1125
  if (payload.summary && summaryEl.style.display === 'none') {
643
1126
  summaryEl.textContent = payload.summary;
644
1127
  summaryEl.style.display = 'block';
1128
+ placeTopLeftControls();
645
1129
  }
646
- maybeShowSidePanel(markers, payload.summary);
1130
+ maybeShowSidePanel(markers);
647
1131
  var bounds = bbox.bounds();
648
1132
  if (bounds) fitToBounds(bounds);
649
1133
  }
650
1134
 
651
1135
  function applyBaseMapConfig(baseMapConfig) {
652
1136
  if (!baseMapConfig || typeof baseMapConfig !== 'object') return;
1137
+ currentBaseMapConfig = baseMapConfig;
653
1138
  if (typeof map.setConfigProperty !== 'function') return;
654
1139
  Object.keys(baseMapConfig).forEach(function(key) {
655
1140
  try { map.setConfigProperty('basemap', key, baseMapConfig[key]); } catch (_) { /* not a Standard-based style */ }
@@ -668,9 +1153,7 @@ ${initialDataScript}
668
1153
  ? 'standard-satellite'
669
1154
  : (payload.baseStyle === 'standard' ? 'standard' : null);
670
1155
  if (desiredBaseStyleId && desiredBaseStyleId !== currentBaseStyleId) {
671
- currentBaseStyleId = desiredBaseStyleId;
672
- map.setStyle('mapbox://styles/mapbox/' + desiredBaseStyleId);
673
- map.once('style.load', function() { renderBody(payload); });
1156
+ switchBaseStyle(desiredBaseStyleId, function() { renderBody(payload); });
674
1157
  return;
675
1158
  }
676
1159
  renderBody(payload);
@@ -688,21 +1171,14 @@ ${initialDataScript}
688
1171
  // Covers both inline markers (a caller-composed payload) and self-fetch
689
1172
  // markers (merged in later via mergeAdditionalPayload) — see
690
1173
  // maybeShowSidePanel below. Whichever path produced id-bearing POI
691
- // markers gets the same results panel.
692
- maybeShowSidePanel(markers, payload.summary);
693
-
694
- // Legend
695
- if (Array.isArray(payload.legend) && payload.legend.length > 0) {
696
- legendEl.innerHTML = payload.legend.map(function(row) {
697
- var op = (typeof row.opacity === 'number') ? row.opacity : 1;
698
- return '<div class="row"><span class="swatch" style="background:' +
699
- escapeAttr(row.color) + ';opacity:' + op + '"></span>' +
700
- escapeText(row.label) + '</div>';
701
- }).join('');
702
- legendEl.style.display = 'block';
703
- } else {
704
- legendEl.style.display = 'none';
705
- }
1174
+ // markers gets the same results carousel.
1175
+ maybeShowSidePanel(markers);
1176
+
1177
+ payloadLegend = Array.isArray(payload.legend) ? payload.legend : [];
1178
+ // showTraffic set by the caller wins; left out, keep whatever the user
1179
+ // picked with the Traffic button (a style switch dropped the layer, so
1180
+ // setTraffic puts it back).
1181
+ setTraffic(typeof payload.showTraffic === 'boolean' ? payload.showTraffic : trafficOn);
706
1182
 
707
1183
  loadingEl.style.display = 'none';
708
1184
  requestSizeToFit();
@@ -734,6 +1210,9 @@ ${initialDataScript}
734
1210
  if (!data) return;
735
1211
  var src = map.getSource(payload.defer.layerId);
736
1212
  if (src && typeof src.setData === 'function') src.setData(data);
1213
+ drawnLayers.forEach(function(layer) {
1214
+ if (layer.id === payload.defer.layerId) layer.data = data;
1215
+ });
737
1216
  },
738
1217
  function() { /* ignore — keep what we rendered inline */ }
739
1218
  );
@@ -1393,7 +1872,7 @@ ${initialDataScript}
1393
1872
  var features = [];
1394
1873
  points.forEach(function(f, i) {
1395
1874
  var props = f.properties || {};
1396
- var popupParts = [(i + 1) + '. ' + (props.name || 'Result')];
1875
+ var popupParts = [props.name || 'Result'];
1397
1876
  var addr = props.full_address || props.place_formatted;
1398
1877
  if (addr) popupParts.push(addr);
1399
1878
  if (typeof props.distance === 'number') {
@@ -1402,16 +1881,15 @@ ${initialDataScript}
1402
1881
  var coords = f.geometry.coordinates;
1403
1882
  // id/name/category/distanceMeters are the same generic POI fields an
1404
1883
  // inline (hand-composed) marker can carry — see maybeShowSidePanel,
1405
- // which builds the results panel from either source identically.
1406
- // mapbox_id is missing on the rare result that lacks one; the panel
1884
+ // which builds the results carousel from either source identically.
1885
+ // mapbox_id is missing on the rare result that lacks one; the carousel
1407
1886
  // just skips that row rather than show an unclickable/unenrichable
1408
1887
  // entry.
1409
1888
  markers.push({
1410
1889
  id: props.mapbox_id,
1411
1890
  coordinates: coords,
1412
- style: 'numbered',
1413
- label: String(i + 1),
1414
- color: '#f97316',
1891
+ style: 'place',
1892
+ maki: props.maki,
1415
1893
  popup: popupParts.join(' — '),
1416
1894
  name: props.name || ('Result ' + (i + 1)),
1417
1895
  category: Array.isArray(props.poi_category) ? props.poi_category[0] : undefined,
@@ -1435,11 +1913,11 @@ ${initialDataScript}
1435
1913
  data: { type: 'FeatureCollection', features: features },
1436
1914
  paint: {
1437
1915
  'circle-radius': 6,
1438
- 'circle-color': '#f97316',
1439
- 'circle-opacity': 0.15,
1916
+ 'circle-color': '#0f172a',
1917
+ 'circle-opacity': 0.1,
1440
1918
  'circle-stroke-width': 1,
1441
- 'circle-stroke-color': '#f97316',
1442
- 'circle-stroke-opacity': 0.4
1919
+ 'circle-stroke-color': '#0f172a',
1920
+ 'circle-stroke-opacity': 0.25
1443
1921
  }
1444
1922
  }
1445
1923
  ] : [];
@@ -1447,27 +1925,27 @@ ${initialDataScript}
1447
1925
  return { summary: summary, layers: layers, markers: markers };
1448
1926
  }
1449
1927
 
1450
- // --- Side panel: result list + Place Details enrichment ----------------
1928
+ // --- Results carousel: one card per place + Place Details enrichment ---
1451
1929
  // Triggered generically by maybeShowSidePanel for ANY marker set that
1452
1930
  // includes id-bearing entries — self-fetch search/category-search
1453
1931
  // results (via mergeAdditionalPayload) and hand-composed inline markers
1454
1932
  // from render_map_tool's own "markers" input (via renderBody) both funnel
1455
1933
  // through the same path. Renders the initial (Search Box-only, or
1456
- // whatever the caller already provided) list immediately so the panel
1457
- // never blocks on the slower enrichment call. sourceLabel is plain text
1458
- // shown as the panel heading (e.g. the query, category, or the payload's
1459
- // own summary).
1934
+ // whatever the caller already provided) cards immediately so the
1935
+ // carousel never blocks on the slower enrichment call.
1460
1936
  function derivePanelItems(markers) {
1461
1937
  if (!Array.isArray(markers)) return [];
1462
1938
  var items = [];
1463
1939
  markers.forEach(function(m) {
1464
1940
  if (!m || !m.id) return;
1465
- // The pin's own label, so a row reads the same as its pin ("A" too).
1941
+ // The pin's own label, so a card reads the same as its pin ("A" too).
1942
+ // A place pin has none, and neither does its card.
1466
1943
  var badge = isWholeNumber(m.label) ? parseInt(m.label, 10)
1467
- : (m.label ? String(m.label) : items.length + 1);
1944
+ : (m.label ? String(m.label) : null);
1468
1945
  items.push({
1469
1946
  id: m.id,
1470
1947
  number: badge,
1948
+ icon: placeIcon(m.maki, m.category),
1471
1949
  name: m.name || ('Result ' + (items.length + 1)),
1472
1950
  category: m.category,
1473
1951
  distanceMeters: typeof m.distanceMeters === 'number' ? m.distanceMeters : undefined,
@@ -1479,16 +1957,16 @@ ${initialDataScript}
1479
1957
  // Exposed so tests can exercise the pure derivation logic without a DOM.
1480
1958
  window.__derivePanelItems = derivePanelItems;
1481
1959
 
1482
- // Adds rows for places not yet in this render's panel and enriches just
1483
- // those. A response is applied only if no newer render has started since
1484
- // (panelGeneration), so a slow lookup can't write into a newer panel.
1485
- function maybeShowSidePanel(markers, summary) {
1960
+ // Adds cards for places not yet in this render's carousel and enriches
1961
+ // just those. A response is applied only if no newer render has started
1962
+ // since (panelGeneration), so a slow lookup can't write into newer cards.
1963
+ function maybeShowSidePanel(markers) {
1486
1964
  var items = derivePanelItems(markers).filter(function(it) {
1487
1965
  return !panelIds[it.id];
1488
1966
  });
1489
1967
  if (items.length === 0) return;
1490
1968
  items.forEach(function(it) { panelIds[it.id] = true; });
1491
- appendPanelRows(items, summary || 'Results');
1969
+ appendCards(items);
1492
1970
  var generation = panelGeneration;
1493
1971
  fetchPlaceDetailsBatch(
1494
1972
  items.map(function(it) { return it.id; }),
@@ -1499,109 +1977,334 @@ ${initialDataScript}
1499
1977
  });
1500
1978
  }
1501
1979
 
1502
- // Keyed by mapbox_id -> { li, meta } elements, so enrichSidePanel can
1503
- // update a row directly once Place Details resolves, without re-parsing
1504
- // panelEl's markup back into a queryable DOM (innerHTML + querySelector
1980
+ // Keyed by mapbox_id -> { li, meta, body } elements, so enrichSidePanel
1981
+ // can update a card directly once Place Details resolves, without
1982
+ // re-parsing markup back into a queryable DOM (innerHTML + querySelector
1505
1983
  // round-trips don't work in every MCP Apps host's iframe sandbox, and
1506
1984
  // building real nodes up front is simpler to reason about either way).
1507
- // Reset by clearPanel, same lifecycle as the panel itself.
1985
+ // Reset by clearPanel, same lifecycle as the carousel itself.
1508
1986
  var panelRowsById = {};
1509
1987
  // Bumped by teardown(), so responses from an earlier render are dropped.
1510
1988
  var panelGeneration = 0;
1511
1989
  // Id-bearing markers numbered so far this render, and the ids listed.
1512
1990
  var panelCount = 0;
1513
1991
  var panelIds = {};
1514
- // The viewer's collapse choice, kept across renders; null until the first
1515
- // panel decides a default from the map's width.
1516
- var panelCollapsed = null;
1517
- var panelListEl = null;
1992
+ var cardListEl = null;
1993
+ var cardOrder = [];
1994
+ var activePlaceId = null;
1995
+
1996
+ // classList when there is one: GL JS adds its own classes to a marker's
1997
+ // element, which assigning className would wipe out.
1998
+ function setActiveClass(el, on) {
1999
+ if (!el) return;
2000
+ if (el.classList && typeof el.classList.toggle === 'function') {
2001
+ el.classList.toggle('active', on);
2002
+ return;
2003
+ }
2004
+ var names = String(el.className || '').split(' ').filter(function(c) {
2005
+ return c && c !== 'active';
2006
+ });
2007
+ if (on) names.push('active');
2008
+ el.className = names.join(' ');
2009
+ }
2010
+
2011
+ // Highlights one place: its card gets a border and its pin inverts and
2012
+ // shows the name. scrollCard brings the card into view (a pin was
2013
+ // clicked); panTo moves the map when the pin is hidden (a card was
2014
+ // picked).
2015
+ function selectPlace(id, opts) {
2016
+ var row = panelRowsById[id];
2017
+ if (!row) return;
2018
+ opts = opts || {};
2019
+ if (activePlaceId !== id) {
2020
+ if (activePlaceId) {
2021
+ var prevRow = panelRowsById[activePlaceId];
2022
+ var prevPin = markersById[activePlaceId];
2023
+ if (prevRow) setActiveClass(prevRow.li, false);
2024
+ if (prevPin && prevPin.el) {
2025
+ setActiveClass(prevPin.el, false);
2026
+ prevPin.el.style.zIndex = '';
2027
+ }
2028
+ }
2029
+ activePlaceId = id;
2030
+ setActiveClass(row.li, true);
2031
+ var pin = markersById[id];
2032
+ if (pin && pin.el) {
2033
+ setActiveClass(pin.el, true);
2034
+ pin.el.style.zIndex = '2';
2035
+ }
2036
+ }
2037
+ if (opts.scrollCard && typeof row.li.scrollIntoView === 'function') {
2038
+ row.li.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
2039
+ }
2040
+ if (opts.panTo) panToIfHidden(id);
2041
+ }
2042
+ window.__selectPlace = selectPlace;
1518
2043
 
1519
- function buildPanelRow(it) {
2044
+ // Moves the map only when the pin, or the name label under it, is off
2045
+ // screen or under the carousel, so swiping through nearby results
2046
+ // doesn't keep shifting it.
2047
+ var PIN_LABEL_ROOM = 52;
2048
+ function panToIfHidden(id) {
2049
+ var entry = markersById[id];
2050
+ if (!entry || !map || typeof map.project !== 'function') return;
2051
+ var lngLat = entry.marker.getLngLat();
2052
+ var p = map.project(lngLat);
2053
+ var el = document.getElementById('map');
2054
+ var width = (el && el.clientWidth) || 0;
2055
+ var height = (el && el.clientHeight) || 0;
2056
+ if (!width || !height) return;
2057
+ var visibleBottom = height - carouselTop() - PIN_LABEL_ROOM;
2058
+ if (p.x >= 24 && p.x <= width - 24 && p.y >= 24 && p.y <= visibleBottom) return;
2059
+ // Just far enough to bring it 24px inside the visible area, so the
2060
+ // pins around it mostly stay in view.
2061
+ var x = Math.min(Math.max(p.x, 48), width - 48);
2062
+ var y = Math.min(Math.max(p.y, 48), Math.max(48, visibleBottom - 24));
2063
+ map.panBy([p.x - x, p.y - y], { duration: 500 });
2064
+ }
2065
+
2066
+ function buildCard(it) {
1520
2067
  var li = document.createElement('li');
1521
- li.className = 'panel-item';
2068
+ li.className = 'card';
1522
2069
 
1523
- var badge = document.createElement('span');
1524
- badge.className = 'panel-badge';
1525
- badge.textContent = String(it.number);
2070
+ var badge = null;
2071
+ if (it.number !== null && it.number !== undefined) {
2072
+ badge = document.createElement('span');
2073
+ badge.className = 'card-badge';
2074
+ badge.textContent = String(it.number);
2075
+ }
1526
2076
  // Match the pin when the caller colored it (e.g. gray for "other
1527
2077
  // results" next to a legend); the default orange is in the CSS.
1528
- if (typeof it.color === 'string' && it.color.toLowerCase() !== '#f97316' &&
2078
+ if (badge && typeof it.color === 'string' && it.color.toLowerCase() !== '#f97316' &&
1529
2079
  (/^#[0-9a-f]{3,8}$/i).test(it.color)) {
1530
2080
  badge.style.background = it.color;
1531
- badge.style.color = '#fff';
2081
+ badge.style.color = badgeTextColor(it.color);
1532
2082
  }
1533
2083
 
1534
2084
  var name = document.createElement('div');
1535
- name.className = 'panel-name';
2085
+ name.className = 'card-name';
1536
2086
  name.textContent = it.name;
1537
2087
 
1538
- var metaParts = [];
1539
- if (it.category) metaParts.push(it.category);
1540
- if (typeof it.distanceMeters === 'number') {
1541
- metaParts.push(Math.round(it.distanceMeters) + ' m');
1542
- }
1543
2088
  var meta = document.createElement('div');
1544
- meta.className = 'panel-meta';
1545
- meta.textContent = metaParts.join(' · ');
2089
+ meta.className = 'card-meta';
2090
+ meta.textContent = cardMetaText(it, null);
1546
2091
 
1547
2092
  var body = document.createElement('div');
2093
+ body.className = 'card-body';
1548
2094
  body.appendChild(name);
1549
2095
  body.appendChild(meta);
1550
- li.appendChild(badge);
2096
+ // Placeholder picture, replaced by the photo if one loads (see
2097
+ // loadPanelPhoto). Shown first by CSS order.
2098
+ var thumb = document.createElement('span');
2099
+ thumb.className = 'card-thumb empty';
2100
+ thumb.innerHTML = iconSvg(it.icon);
2101
+ if (typeof thumb.setAttribute === 'function') {
2102
+ thumb.setAttribute('role', 'img');
2103
+ thumb.setAttribute('aria-label', it.name);
2104
+ }
2105
+ if (badge) li.appendChild(badge);
1551
2106
  li.appendChild(body);
2107
+ li.appendChild(thumb);
1552
2108
 
1553
2109
  li.addEventListener('click', function() {
1554
- var entry = markersById[it.id];
1555
- if (entry) map.flyTo({ center: entry.marker.getLngLat(), zoom: 15, duration: 500 });
2110
+ selectPlace(it.id, { scrollCard: true, panTo: true });
1556
2111
  });
1557
2112
 
1558
- panelRowsById[it.id] = { li: li, meta: meta };
2113
+ panelRowsById[it.id] = { li: li, meta: meta, body: body, thumb: thumb, item: it };
1559
2114
  return li;
1560
2115
  }
1561
2116
 
1562
- function clearPanel() {
1563
- while (panelEl.firstChild) panelEl.removeChild(panelEl.firstChild);
1564
- panelRowsById = {};
1565
- panelListEl = null;
2117
+ // "Cafe · $$ · 120 m". Search Box categories come lowercase.
2118
+ function cardMetaText(it, price) {
2119
+ var parts = [];
2120
+ if (it.category) parts.push(it.category.charAt(0).toUpperCase() + it.category.slice(1));
2121
+ if (price) parts.push(price);
2122
+ if (typeof it.distanceMeters === 'number') {
2123
+ parts.push(Math.round(it.distanceMeters) + ' m');
2124
+ }
2125
+ return parts.join(' · ');
1566
2126
  }
1567
2127
 
1568
- function applyPanelCollapsed() {
1569
- panelEl.className = panelCollapsed ? 'collapsed' : '';
2128
+ // Places API attributes.price_level as dollar signs. Unknown levels show
2129
+ // nothing rather than a guess.
2130
+ var PRICE_SIGNS = { 'cheap': '$', 'moderate': '$$', 'expensive': '$$$', 'very expensive': '$$$$' };
2131
+ function priceSigns(level) {
2132
+ if (typeof level !== 'string') return null;
2133
+ return PRICE_SIGNS[level.trim().toLowerCase()] || null;
1570
2134
  }
2135
+ window.__priceSigns = priceSigns;
1571
2136
 
1572
- function togglePanel() {
1573
- panelCollapsed = !panelCollapsed;
1574
- applyPanelCollapsed();
1575
- if (lastFitBounds) fitToBounds(lastFitBounds);
2137
+ function clearPanel() {
2138
+ while (carouselEl.firstChild) carouselEl.removeChild(carouselEl.firstChild);
2139
+ panelRowsById = {};
2140
+ cardListEl = null;
2141
+ cardOrder = [];
2142
+ activePlaceId = null;
2143
+ }
2144
+
2145
+ // Swiping selects whichever card ends up in the middle.
2146
+ var scrollTimer = null;
2147
+ function onCardScroll() {
2148
+ if (scrollTimer) clearTimeout(scrollTimer);
2149
+ scrollTimer = setTimeout(function() {
2150
+ scrollTimer = null;
2151
+ if (!cardListEl) return;
2152
+ var center = (cardListEl.scrollLeft || 0) + (cardListEl.clientWidth || 0) / 2;
2153
+ var best = null;
2154
+ var bestDist = Infinity;
2155
+ cardOrder.forEach(function(id) {
2156
+ var row = panelRowsById[id];
2157
+ if (!row) return;
2158
+ var mid = (row.li.offsetLeft || 0) + (row.li.offsetWidth || 0) / 2;
2159
+ var d = Math.abs(mid - center);
2160
+ if (d < bestDist) { bestDist = d; best = id; }
2161
+ });
2162
+ if (best && best !== activePlaceId) selectPlace(best, { panTo: true });
2163
+ }, 120);
1576
2164
  }
1577
2165
 
1578
- // Creates the panel on the render's first id-bearing markers, and adds
1579
- // rows to it for later ones (e.g. search results merged in after inline
1580
- // markers), so every numbered pin has a row.
1581
- function appendPanelRows(items, sourceLabel) {
2166
+ // Creates the carousel on the render's first id-bearing markers, and adds
2167
+ // cards for later ones (e.g. search results merged in after inline
2168
+ // markers), so every numbered pin has a card. The first card starts
2169
+ // selected.
2170
+ function appendCards(items) {
1582
2171
  if (!Array.isArray(items) || items.length === 0) return;
1583
- if (!panelListEl) {
1584
- var head = document.createElement('div');
1585
- head.className = 'panel-head';
1586
- head.textContent = sourceLabel || 'Results';
1587
- head.addEventListener('click', togglePanel);
1588
-
1589
- panelListEl = document.createElement('ul');
1590
- panelListEl.className = 'panel-list';
1591
-
1592
- panelEl.appendChild(head);
1593
- panelEl.appendChild(panelListEl);
1594
- // On a narrow map the open panel would cover most of it.
1595
- if (panelCollapsed === null) {
1596
- var width = mapContainerWidth();
1597
- panelCollapsed = width > 0 && width < 560;
2172
+ var created = false;
2173
+ if (!cardListEl) {
2174
+ cardListEl = document.createElement('ul');
2175
+ cardListEl.className = 'card-list';
2176
+ cardListEl.addEventListener('scroll', onCardScroll);
2177
+ carouselEl.appendChild(cardListEl);
2178
+ carouselEl.style.display = 'block';
2179
+ created = true;
2180
+ }
2181
+ var list = cardListEl;
2182
+ items.forEach(function(it) {
2183
+ list.appendChild(buildCard(it));
2184
+ cardOrder.push(it.id);
2185
+ });
2186
+ // The legends (bottom-left) move up above the cards.
2187
+ legendsEl.style.bottom = (carouselTop() + 8) + 'px';
2188
+ if (created) selectPlace(items[0].id);
2189
+ }
2190
+
2191
+ // Opening hours from a Places API opening_hours string, which uses OSM
2192
+ // syntax: "Mo-Fr 07:00-19:00; Sa,Su 08:00-17:00", "Mo 09:00-12:00,13:00-17:00",
2193
+ // "Su off", "24/7". Returns null for anything else (holidays, months, week
2194
+ // numbers), so a card shows no hours rather than wrong ones.
2195
+ //
2196
+ // With the place's time zone (Places telemetry.tags.timezone) we know its
2197
+ // clock, so the card says whether it's open now: { state: 'open', text:
2198
+ // 'until 8:30 PM' } or { state: 'closed', text: '· opens 7:30 AM' }.
2199
+ // Without one we only know the viewer's day, so the card shows today's
2200
+ // hours: { state: 'hours', text: 'Open 7:30 AM–3:00 PM' }. Times use the
2201
+ // viewer's locale (7:30–15:00 in en-GB).
2202
+ var OSM_WEEK = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
2203
+ var INTL_WEEK = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
2204
+ var HOURS_RANGE = /^[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}$/;
2205
+ function osmDaysInclude(days, day) {
2206
+ var parts = days.split(',');
2207
+ var hit = false;
2208
+ for (var i = 0; i < parts.length; i++) {
2209
+ var ends = parts[i].split('-');
2210
+ var a = OSM_WEEK.indexOf(ends[0]);
2211
+ var b = ends.length === 2 ? OSM_WEEK.indexOf(ends[1]) : a;
2212
+ if (a === -1 || b === -1 || ends.length > 2) return null;
2213
+ if (a <= b ? (day >= a && day <= b) : (day >= a || day <= b)) hit = true;
2214
+ }
2215
+ return hit;
2216
+ }
2217
+ function toMinutes(hhmm) {
2218
+ return parseInt(hhmm.slice(0, 2), 10) * 60 + parseInt(hhmm.slice(3, 5), 10);
2219
+ }
2220
+ // A day's opening ranges as [startMinute, endMinute] pairs (end <= start
2221
+ // runs past midnight), 'always' for 24/7, or null when unparseable.
2222
+ function osmDayRanges(spec, day) {
2223
+ if (spec === '24/7') return 'always';
2224
+ var result = [];
2225
+ var rules = spec.split(';');
2226
+ for (var i = 0; i < rules.length; i++) {
2227
+ var rule = rules[i].trim();
2228
+ if (!rule) continue;
2229
+ var space = rule.indexOf(' ');
2230
+ if (space === -1) return null;
2231
+ var applies = osmDaysInclude(rule.slice(0, space), day);
2232
+ if (applies === null) return null;
2233
+ var times = rule.slice(space + 1).trim();
2234
+ var ranges = times === 'off' || times === 'closed' ? [] : times.split(',');
2235
+ var parsed = [];
2236
+ for (var j = 0; j < ranges.length; j++) {
2237
+ var r = ranges[j].trim();
2238
+ if (!HOURS_RANGE.test(r)) return null;
2239
+ parsed.push([toMinutes(r.slice(0, 5)), toMinutes(r.slice(6, 11))]);
2240
+ }
2241
+ // In OSM a later rule for the same day overrides an earlier one.
2242
+ if (applies) result = parsed;
2243
+ }
2244
+ return result;
2245
+ }
2246
+ // The place's weekday (0 = Monday) and minute of the day, or null for a
2247
+ // missing or unknown time zone.
2248
+ function clockIn(timeZone, now) {
2249
+ if (typeof timeZone !== 'string' || !timeZone) return null;
2250
+ var parts;
2251
+ try {
2252
+ parts = new Intl.DateTimeFormat('en-US', {
2253
+ timeZone: timeZone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
2254
+ }).formatToParts(now);
2255
+ } catch (e) {
2256
+ return null;
2257
+ }
2258
+ var p = {};
2259
+ parts.forEach(function(part) { p[part.type] = part.value; });
2260
+ var day = INTL_WEEK.indexOf(p.weekday);
2261
+ if (day === -1) return null;
2262
+ return { day: day, minutes: (parseInt(p.hour, 10) % 24) * 60 + parseInt(p.minute, 10) };
2263
+ }
2264
+ function formatMinutes(minutes, locale) {
2265
+ var d = new Date(2000, 0, 1, Math.floor(minutes / 60) % 24, minutes % 60);
2266
+ try {
2267
+ return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(d);
2268
+ } catch (e) {
2269
+ return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(d);
2270
+ }
2271
+ }
2272
+ function hoursToday(spec, now, timeZone, locale) {
2273
+ if (typeof spec !== 'string') return null;
2274
+ var text = spec.trim();
2275
+ if (!text) return null;
2276
+ var clock = clockIn(timeZone, now);
2277
+ var day = clock ? clock.day : (now.getDay() + 6) % 7;
2278
+ var today = osmDayRanges(text, day);
2279
+ if (today === null) return null;
2280
+ if (today === 'always') return { state: 'open', text: '24 hours' };
2281
+ var fmt = function(m) { return formatMinutes(m, locale); };
2282
+ if (!clock) {
2283
+ if (today.length === 0) return { state: 'closed', text: 'today' };
2284
+ return {
2285
+ state: 'hours',
2286
+ text: 'Open ' + today.map(function(r) { return fmt(r[0]) + '–' + fmt(r[1]); }).join(', ')
2287
+ };
2288
+ }
2289
+ var t = clock.minutes;
2290
+ // Still open from a range that started yesterday and runs past midnight.
2291
+ var yesterday = osmDayRanges(text, (day + 6) % 7) || [];
2292
+ for (var i = 0; i < yesterday.length; i++) {
2293
+ if (yesterday[i][1] <= yesterday[i][0] && t < yesterday[i][1]) {
2294
+ return { state: 'open', text: 'until ' + fmt(yesterday[i][1]) };
1598
2295
  }
1599
- applyPanelCollapsed();
1600
- panelEl.style.display = 'flex';
1601
2296
  }
1602
- var list = panelListEl;
1603
- items.forEach(function(it) { list.appendChild(buildPanelRow(it)); });
2297
+ var next = null;
2298
+ for (var j = 0; j < today.length; j++) {
2299
+ var s = today[j][0];
2300
+ var e = today[j][1];
2301
+ var overnight = e <= s;
2302
+ if (t >= s && (overnight || t < e)) return { state: 'open', text: 'until ' + fmt(e) };
2303
+ if (t < s && (next === null || s < next)) next = s;
2304
+ }
2305
+ return { state: 'closed', text: next === null ? '' : '· opens ' + fmt(next) };
1604
2306
  }
2307
+ window.__hoursToday = hoursToday;
1605
2308
 
1606
2309
  // Search Box results mix Mapbox-native POI ids (decode to
1607
2310
  // "urn:mbxpoi:<uuid>") with OSM-sourced ones (decode to
@@ -1627,7 +2330,7 @@ ${initialDataScript}
1627
2330
 
1628
2331
  // Places API Details endpoint — batch form. Mirrors the single-record
1629
2332
  // GET src/tools/place-details-tool/PlaceDetailsTool.ts calls server-side,
1630
- // but the panel needs details for every result at once, so this uses the
2333
+ // but the carousel needs details for every result at once, so this uses the
1631
2334
  // POST /details/retrieve batch form (up to 100 ids) instead of one
1632
2335
  // request per pin. Docs: https://docs.mapbox.com/api/search/places/
1633
2336
  // Like every other self-fetch call, this runs client-side against the
@@ -1662,18 +2365,18 @@ ${initialDataScript}
1662
2365
  });
1663
2366
  };
1664
2367
  return post(compatibleIds.slice(0, 100), false)
1665
- // Enrichment is a nice-to-have on top of an already-rendered panel —
2368
+ // Enrichment is a nice-to-have on top of already-rendered cards —
1666
2369
  // Public Preview quota (1,000 records/month) or a transient error
1667
2370
  // here should never surface as a map error, just leave the
1668
- // Search-Box-only rows as they are.
2371
+ // Search-Box-only cards as they are.
1669
2372
  .catch(function() { return null; });
1670
2373
  }
1671
2374
 
1672
2375
  // Place Details photos are hosted wherever each business's site hosts
1673
2376
  // them, so the in-chat CSP (mapAppCsp.ts) can't allow them. Inside a host,
1674
2377
  // the page asks the server for the photo (PlacePhotoResource, by mapbox_id)
1675
- // and shows it as a data: URL. A standalone page from export_map_html_tool
1676
- // has no host and no CSP, so it loads the photo URL directly.
2378
+ // and shows it as a data: URL. A page with no host has no CSP of ours, so
2379
+ // it loads the photo URL directly.
1677
2380
  var MAX_PANEL_PHOTOS = 10;
1678
2381
  var EMBEDDED = window.parent !== window;
1679
2382
  var PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
@@ -1681,17 +2384,16 @@ ${initialDataScript}
1681
2384
 
1682
2385
  function loadPanelPhoto(row, id, photoUrl) {
1683
2386
  var show = function(src) {
1684
- // A later render may have replaced the panel while this loaded.
2387
+ // A later render may have replaced the carousel while this loaded.
1685
2388
  if (panelRowsById[id] !== row) return;
1686
- var thumb = document.createElement('span');
1687
- thumb.className = 'panel-thumb';
1688
- thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")';
1689
- row.li.appendChild(thumb);
2389
+ row.thumb.className = 'card-thumb';
2390
+ row.thumb.innerHTML = '';
2391
+ row.thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")';
1690
2392
  };
1691
2393
  if (!EMBEDDED || !hostAnswered) {
1692
- // No MCP host (a standalone or framed exported page), so no CSP of
1693
- // ours either. Load it first: some sites refuse hotlinked photos (403), which
1694
- // would leave an empty box in the row.
2394
+ // No MCP host (a standalone or framed page), so no CSP of ours
2395
+ // either. Load it first: some sites refuse hotlinked photos (403),
2396
+ // which would leave an empty box on the card.
1695
2397
  if (photoUrl.indexOf('https://') !== 0 || typeof Image !== 'function') return;
1696
2398
  var img = new Image();
1697
2399
  img.onload = function() { show(photoUrl); };
@@ -1716,23 +2418,36 @@ ${initialDataScript}
1716
2418
  detailsResult.results.forEach(function(place) {
1717
2419
  if (!place || !place.mapbox_id) return;
1718
2420
  var row = panelRowsById[place.mapbox_id];
1719
- if (row) {
1720
- var photoUrl = place.photos && place.photos[0] && place.photos[0].url;
1721
- if (typeof photoUrl === 'string' && photosRequested < MAX_PANEL_PHOTOS) {
1722
- photosRequested++;
1723
- loadPanelPhoto(row, place.mapbox_id, photoUrl);
1724
- }
1725
- if (place.score && typeof place.score.popularity === 'number') {
1726
- var extra = Math.round(place.score.popularity * 100) + '% popularity';
1727
- row.meta.textContent = row.meta.textContent ? row.meta.textContent + ' · ' + extra : extra;
1728
- }
2421
+ if (!row) return;
2422
+ var photoUrl = place.photos && place.photos[0] && place.photos[0].url;
2423
+ if (typeof photoUrl === 'string' && photosRequested < MAX_PANEL_PHOTOS) {
2424
+ photosRequested++;
2425
+ loadPanelPhoto(row, place.mapbox_id, photoUrl);
1729
2426
  }
1730
-
1731
- var entry = markersById[place.mapbox_id];
1732
- if (entry && place.phone) {
1733
- entry.marker.setPopup(
1734
- new mapboxgl.Popup().setText(entry.popupText + ' — ' + place.phone)
1735
- );
2427
+ var price = priceSigns(place.attributes && place.attributes.price_level);
2428
+ if (price) row.meta.textContent = cardMetaText(row.item, price);
2429
+ // Hours on a second line: a green "Open" or red "Closed" when we know
2430
+ // the place's clock. Places has no star rating, and score.popularity
2431
+ // isn't one, so the card shows neither.
2432
+ var timeZone = place.telemetry && place.telemetry.tags && place.telemetry.tags.timezone;
2433
+ var hours = hoursToday(place.opening_hours, new Date(), timeZone);
2434
+ if (hours) {
2435
+ var line = document.createElement('div');
2436
+ line.className = 'card-extra';
2437
+ if (hours.state === 'hours') {
2438
+ line.textContent = hours.text;
2439
+ } else {
2440
+ var status = document.createElement('span');
2441
+ status.className = hours.state === 'open' ? 'hours-open' : 'hours-closed';
2442
+ status.textContent = hours.state === 'open' ? 'Open' : 'Closed';
2443
+ line.appendChild(status);
2444
+ if (hours.text) {
2445
+ var rest = document.createElement('span');
2446
+ rest.textContent = ' ' + hours.text;
2447
+ line.appendChild(rest);
2448
+ }
2449
+ }
2450
+ row.body.appendChild(line);
1736
2451
  }
1737
2452
  });
1738
2453
  }
@@ -2048,7 +2763,7 @@ ${initialDataScript}
2048
2763
 
2049
2764
  // Mirrors the deleted server-side buildGroundLocationPayload (now
2050
2765
  // client-only since the map preview self-fetches): a dark origin pin plus
2051
- // numbered orange POI markers. Isochrone contours aren't drawn here either
2766
+ // place pins for the POIs. Isochrone contours aren't drawn here either
2052
2767
  // — the original never rendered them, only a contour-minutes summary.
2053
2768
  function buildGroundLocationMiniPayload(place, longitude, latitude, pois) {
2054
2769
  var markers = [
@@ -2060,7 +2775,7 @@ ${initialDataScript}
2060
2775
  }
2061
2776
  ];
2062
2777
  (pois || []).forEach(function(poi, i) {
2063
- var parts = [(i + 1) + '. ' + poi.name];
2778
+ var parts = [poi.name];
2064
2779
  if (poi.address) parts.push(poi.address);
2065
2780
  if (typeof poi.distance_meters === 'number') {
2066
2781
  parts.push(Math.round(poi.distance_meters) + ' m');
@@ -2068,9 +2783,7 @@ ${initialDataScript}
2068
2783
  markers.push({
2069
2784
  id: poi.id,
2070
2785
  coordinates: [poi.longitude, poi.latitude],
2071
- style: 'numbered',
2072
- label: String(i + 1),
2073
- color: '#f97316',
2786
+ style: 'place',
2074
2787
  popup: parts.join(' — '),
2075
2788
  name: poi.name,
2076
2789
  category: poi.category,