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

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 (163) 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 +977 -252
  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/MapboxApiBasedTool.d.ts.map +1 -1
  32. package/dist/commonjs/tools/MapboxApiBasedTool.js +6 -2
  33. package/dist/commonjs/tools/MapboxApiBasedTool.js.map +1 -1
  34. package/dist/commonjs/tools/category-search-tool/CategorySearchTool.d.ts.map +1 -1
  35. package/dist/commonjs/tools/category-search-tool/CategorySearchTool.js +1 -1
  36. package/dist/commonjs/tools/category-search-tool/CategorySearchTool.js.map +1 -1
  37. package/dist/commonjs/tools/list-public-tokens-tool/ListPublicTokensTool.d.ts.map +1 -1
  38. package/dist/commonjs/tools/list-public-tokens-tool/ListPublicTokensTool.js +2 -0
  39. package/dist/commonjs/tools/list-public-tokens-tool/ListPublicTokensTool.js.map +1 -1
  40. package/dist/commonjs/tools/render-map-tool/RenderMapTool.d.ts.map +1 -1
  41. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.d.ts +1 -0
  42. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.d.ts.map +1 -1
  43. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.js +10 -6
  44. package/dist/commonjs/tools/render-map-tool/RenderMapTool.input.schema.js.map +1 -1
  45. package/dist/commonjs/tools/render-map-tool/RenderMapTool.js +26 -12
  46. package/dist/commonjs/tools/render-map-tool/RenderMapTool.js.map +1 -1
  47. package/dist/commonjs/tools/render-map-tool/RenderMapTool.output.schema.d.ts +1 -0
  48. package/dist/commonjs/tools/render-map-tool/RenderMapTool.output.schema.d.ts.map +1 -1
  49. package/dist/commonjs/tools/resource-reader-tool/ResourceReaderTool.d.ts.map +1 -1
  50. package/dist/commonjs/tools/resource-reader-tool/ResourceReaderTool.js +43 -9
  51. package/dist/commonjs/tools/resource-reader-tool/ResourceReaderTool.js.map +1 -1
  52. package/dist/commonjs/tools/search-and-geocode-tool/SearchAndGeocodeTool.d.ts.map +1 -1
  53. package/dist/commonjs/tools/search-and-geocode-tool/SearchAndGeocodeTool.js +1 -1
  54. package/dist/commonjs/tools/search-and-geocode-tool/SearchAndGeocodeTool.js.map +1 -1
  55. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.d.ts +8 -1
  56. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.d.ts.map +1 -1
  57. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.js +113 -90
  58. package/dist/commonjs/tools/static-map-image-tool/StaticMapImageTool.js.map +1 -1
  59. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts +23 -0
  60. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts.map +1 -0
  61. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.js +84 -0
  62. package/dist/commonjs/tools/static-map-image-tool/buildStaticMapRequestUrl.js.map +1 -0
  63. package/dist/commonjs/utils/mapAppPayload.d.ts +15 -6
  64. package/dist/commonjs/utils/mapAppPayload.d.ts.map +1 -1
  65. package/dist/commonjs/utils/mapAppPayload.js +2 -1
  66. package/dist/commonjs/utils/mapAppPayload.js.map +1 -1
  67. package/dist/commonjs/utils/mapboxPublicToken.d.ts +5 -1
  68. package/dist/commonjs/utils/mapboxPublicToken.d.ts.map +1 -1
  69. package/dist/commonjs/utils/mapboxPublicToken.js +6 -1
  70. package/dist/commonjs/utils/mapboxPublicToken.js.map +1 -1
  71. package/dist/commonjs/utils/responseRef.d.ts +1 -1
  72. package/dist/commonjs/utils/responseRef.d.ts.map +1 -1
  73. package/dist/commonjs/utils/responseRef.js +1 -1
  74. package/dist/commonjs/utils/responseRef.js.map +1 -1
  75. package/dist/commonjs/utils/selfFetchRef.d.ts +24 -0
  76. package/dist/commonjs/utils/selfFetchRef.d.ts.map +1 -1
  77. package/dist/commonjs/utils/selfFetchRef.js +68 -0
  78. package/dist/commonjs/utils/selfFetchRef.js.map +1 -1
  79. package/dist/commonjs/utils/serverInstructions.js +1 -1
  80. package/dist/commonjs/utils/serverInstructions.js.map +1 -1
  81. package/dist/commonjs/version.json +3 -3
  82. package/dist/esm/resources/BaseResource.d.ts +7 -0
  83. package/dist/esm/resources/BaseResource.d.ts.map +1 -1
  84. package/dist/esm/resources/BaseResource.js +13 -2
  85. package/dist/esm/resources/BaseResource.js.map +1 -1
  86. package/dist/esm/resources/category-list/CategoryListResource.d.ts +7 -2
  87. package/dist/esm/resources/category-list/CategoryListResource.d.ts.map +1 -1
  88. package/dist/esm/resources/category-list/CategoryListResource.js +14 -4
  89. package/dist/esm/resources/category-list/CategoryListResource.js.map +1 -1
  90. package/dist/esm/resources/place-photo/PlacePhotoResource.d.ts +1 -1
  91. package/dist/esm/resources/place-photo/PlacePhotoResource.js +2 -2
  92. package/dist/esm/resources/place-photo/PlacePhotoResource.js.map +1 -1
  93. package/dist/esm/resources/resourceRegistry.d.ts +2 -1
  94. package/dist/esm/resources/resourceRegistry.d.ts.map +1 -1
  95. package/dist/esm/resources/resourceRegistry.js +10 -5
  96. package/dist/esm/resources/resourceRegistry.js.map +1 -1
  97. package/dist/esm/resources/tool-response/ToolResponseResource.d.ts +6 -5
  98. package/dist/esm/resources/tool-response/ToolResponseResource.d.ts.map +1 -1
  99. package/dist/esm/resources/tool-response/ToolResponseResource.js +29 -5
  100. package/dist/esm/resources/tool-response/ToolResponseResource.js.map +1 -1
  101. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.d.ts +28 -0
  102. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.d.ts.map +1 -0
  103. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.js +59 -0
  104. package/dist/esm/resources/ui-apps/StaticMapViewerUIResource.js.map +1 -0
  105. package/dist/esm/resources/ui-apps/mapAppHtml.d.ts.map +1 -1
  106. package/dist/esm/resources/ui-apps/mapAppHtml.js +977 -252
  107. package/dist/esm/resources/ui-apps/mapAppHtml.js.map +1 -1
  108. package/dist/esm/resources/ui-apps/staticMapViewerHtml.d.ts +17 -0
  109. package/dist/esm/resources/ui-apps/staticMapViewerHtml.d.ts.map +1 -0
  110. package/dist/esm/resources/ui-apps/staticMapViewerHtml.js +177 -0
  111. package/dist/esm/resources/ui-apps/staticMapViewerHtml.js.map +1 -0
  112. package/dist/esm/tools/MapboxApiBasedTool.d.ts.map +1 -1
  113. package/dist/esm/tools/MapboxApiBasedTool.js +6 -2
  114. package/dist/esm/tools/MapboxApiBasedTool.js.map +1 -1
  115. package/dist/esm/tools/category-search-tool/CategorySearchTool.d.ts.map +1 -1
  116. package/dist/esm/tools/category-search-tool/CategorySearchTool.js +2 -2
  117. package/dist/esm/tools/category-search-tool/CategorySearchTool.js.map +1 -1
  118. package/dist/esm/tools/list-public-tokens-tool/ListPublicTokensTool.d.ts.map +1 -1
  119. package/dist/esm/tools/list-public-tokens-tool/ListPublicTokensTool.js +2 -0
  120. package/dist/esm/tools/list-public-tokens-tool/ListPublicTokensTool.js.map +1 -1
  121. package/dist/esm/tools/render-map-tool/RenderMapTool.d.ts.map +1 -1
  122. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.d.ts +1 -0
  123. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.d.ts.map +1 -1
  124. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.js +10 -6
  125. package/dist/esm/tools/render-map-tool/RenderMapTool.input.schema.js.map +1 -1
  126. package/dist/esm/tools/render-map-tool/RenderMapTool.js +26 -12
  127. package/dist/esm/tools/render-map-tool/RenderMapTool.js.map +1 -1
  128. package/dist/esm/tools/render-map-tool/RenderMapTool.output.schema.d.ts +1 -0
  129. package/dist/esm/tools/render-map-tool/RenderMapTool.output.schema.d.ts.map +1 -1
  130. package/dist/esm/tools/resource-reader-tool/ResourceReaderTool.d.ts.map +1 -1
  131. package/dist/esm/tools/resource-reader-tool/ResourceReaderTool.js +43 -9
  132. package/dist/esm/tools/resource-reader-tool/ResourceReaderTool.js.map +1 -1
  133. package/dist/esm/tools/search-and-geocode-tool/SearchAndGeocodeTool.d.ts.map +1 -1
  134. package/dist/esm/tools/search-and-geocode-tool/SearchAndGeocodeTool.js +2 -2
  135. package/dist/esm/tools/search-and-geocode-tool/SearchAndGeocodeTool.js.map +1 -1
  136. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.d.ts +8 -1
  137. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.d.ts.map +1 -1
  138. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.js +114 -91
  139. package/dist/esm/tools/static-map-image-tool/StaticMapImageTool.js.map +1 -1
  140. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts +23 -0
  141. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.d.ts.map +1 -0
  142. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.js +78 -0
  143. package/dist/esm/tools/static-map-image-tool/buildStaticMapRequestUrl.js.map +1 -0
  144. package/dist/esm/utils/mapAppPayload.d.ts +15 -6
  145. package/dist/esm/utils/mapAppPayload.d.ts.map +1 -1
  146. package/dist/esm/utils/mapAppPayload.js +2 -1
  147. package/dist/esm/utils/mapAppPayload.js.map +1 -1
  148. package/dist/esm/utils/mapboxPublicToken.d.ts +5 -1
  149. package/dist/esm/utils/mapboxPublicToken.d.ts.map +1 -1
  150. package/dist/esm/utils/mapboxPublicToken.js +6 -1
  151. package/dist/esm/utils/mapboxPublicToken.js.map +1 -1
  152. package/dist/esm/utils/responseRef.d.ts +1 -1
  153. package/dist/esm/utils/responseRef.d.ts.map +1 -1
  154. package/dist/esm/utils/responseRef.js +1 -1
  155. package/dist/esm/utils/responseRef.js.map +1 -1
  156. package/dist/esm/utils/selfFetchRef.d.ts +24 -0
  157. package/dist/esm/utils/selfFetchRef.d.ts.map +1 -1
  158. package/dist/esm/utils/selfFetchRef.js +67 -0
  159. package/dist/esm/utils/selfFetchRef.js.map +1 -1
  160. package/dist/esm/utils/serverInstructions.js +1 -1
  161. package/dist/esm/utils/serverInstructions.js.map +1 -1
  162. package/dist/esm/version.json +3 -3
  163. package/package.json +1 -1
@@ -31,24 +31,62 @@ export function renderMapAppHtml(params) {
31
31
  <link href="https://api.mapbox.com/mapbox-gl-js/v${glVersion}/mapbox-gl.css" rel="stylesheet">
32
32
  <script src="https://api.mapbox.com/mapbox-gl-js/v${glVersion}/mapbox-gl.js"></script>
33
33
  <style>
34
+ /* Light and dark, following the host's theme (see applyHostContext), or
35
+ the system's when the host sends none. Colors come from the host's own
36
+ style variables (MCP Apps hostContext.styles) when it sends them, so
37
+ the cards match the chat around them; the fallbacks are used
38
+ otherwise. The map, its pins and their name labels stay as they are. */
39
+ :root {
40
+ color-scheme: light;
41
+ --ui-surface: var(--color-background-primary, #ffffff);
42
+ --ui-surface-muted: var(--color-background-secondary, #f1f5f9);
43
+ --ui-text: var(--color-text-primary, #0f172a);
44
+ --ui-text-muted: var(--color-text-secondary, #475569);
45
+ --ui-icon-muted: #64748b;
46
+ --ui-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);
47
+ --ui-error-bg: #ffebee; --ui-error-text: #b71c1c;
48
+ }
49
+ :root[data-theme="dark"] {
50
+ color-scheme: dark;
51
+ --ui-surface: var(--color-background-primary, #0f172a);
52
+ --ui-surface-muted: var(--color-background-secondary, #1e293b);
53
+ --ui-text: var(--color-text-primary, #f1f5f9);
54
+ --ui-text-muted: var(--color-text-secondary, #cbd5e1);
55
+ --ui-icon-muted: #94a3b8;
56
+ --ui-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
57
+ --ui-error-bg: #3b1214; --ui-error-text: #fca5a5;
58
+ }
34
59
  * { margin: 0; padding: 0; box-sizing: border-box; }
35
60
  html, body { width: 100%; height: 100%; overflow: hidden;
36
61
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
37
62
  #map { position: absolute; inset: 0; }
38
63
  #summary {
39
64
  position: absolute; top: 12px; left: 12px; z-index: 10;
40
- background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
65
+ background: var(--ui-surface); color: var(--ui-text);
41
66
  padding: 8px 12px; border-radius: 6px;
42
67
  font-size: 13px; font-weight: 500;
43
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
68
+ box-shadow: var(--ui-shadow);
44
69
  max-width: calc(100% - 24px); white-space: pre-line;
45
70
  }
71
+ /* Bottom offsets keep the Mapbox logo (bottom left) and attribution
72
+ (bottom right) visible, as the Mapbox terms require. */
73
+ #legends {
74
+ position: absolute; bottom: 40px; left: 12px; z-index: 10;
75
+ display: flex; flex-direction: column; align-items: flex-start; gap: 8px;
76
+ }
46
77
  #legend {
47
- position: absolute; bottom: 12px; left: 12px; z-index: 10;
48
- background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
78
+ background: var(--ui-surface); color: var(--ui-text);
49
79
  padding: 8px 12px; border-radius: 6px;
50
- font-size: 11px;
80
+ font-size: 11px; box-shadow: var(--ui-shadow);
81
+ }
82
+ /* Traffic: Fast [4 colors] Slow, below any payload legend. */
83
+ #traffic-legend {
84
+ align-items: center; gap: 8px; padding: 8px; border-radius: 8px;
85
+ background: #fff; color: #05070a; font-size: 14px; line-height: 1.3;
86
+ box-shadow: 0 4px 8px rgba(0,0,0,0.12); white-space: nowrap;
51
87
  }
88
+ #traffic-legend .bar { display: flex; gap: 2px; }
89
+ #traffic-legend .bar span { width: 20px; height: 10px; border-radius: 2px; }
52
90
  #legend .row { display: flex; align-items: center; margin: 2px 0; }
53
91
  #legend .swatch {
54
92
  display: inline-block; width: 14px; height: 14px;
@@ -56,11 +94,11 @@ export function renderMapAppHtml(params) {
56
94
  }
57
95
  #loading {
58
96
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
59
- color: #666; font-size: 16px; z-index: 10; pointer-events: none;
97
+ color: var(--ui-text-muted); font-size: 16px; z-index: 10; pointer-events: none;
60
98
  }
61
99
  #error {
62
100
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
63
- color: #d32f2f; background: #ffebee; border-radius: 8px;
101
+ color: var(--ui-error-text); background: var(--ui-error-bg); border-radius: 8px;
64
102
  padding: 20px; max-width: 400px; text-align: center; z-index: 10;
65
103
  }
66
104
  .marker-badge {
@@ -71,51 +109,99 @@ export function renderMapAppHtml(params) {
71
109
  box-shadow: 0 2px 6px rgba(0,0,0,0.4);
72
110
  cursor: pointer;
73
111
  }
74
- #side-panel {
75
- position: absolute; top: 12px; right: 12px; bottom: 12px; z-index: 10;
76
- width: 280px; max-width: calc(100% - 24px);
77
- background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
78
- border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
79
- display: flex; flex-direction: column; overflow: hidden;
80
- }
81
- #side-panel .panel-head {
82
- padding: 10px 12px; font-size: 13px; font-weight: 600;
83
- border-bottom: 1px solid rgba(255,255,255,0.12); flex: 0 0 auto;
84
- display: flex; justify-content: space-between; gap: 8px; cursor: pointer;
85
- }
86
- /* Click the heading to collapse the list (see togglePanel). */
87
- #side-panel .panel-head::after { content: '▾'; opacity: 0.7; }
88
- #side-panel.collapsed { bottom: auto; }
89
- #side-panel.collapsed .panel-list { display: none; }
90
- #side-panel.collapsed .panel-head { border-bottom: none; }
91
- #side-panel.collapsed .panel-head::after { content: '▸'; }
92
- #side-panel .panel-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; flex: 1; }
93
- #side-panel .panel-item {
94
- display: flex; align-items: flex-start; gap: 6px; padding: 6px; border-radius: 6px; cursor: pointer;
95
- }
96
- #side-panel .panel-item:hover { background: rgba(255,255,255,0.08); }
97
- /* Only added once a photo has loaded (see loadPanelPhoto), at the end of
98
- the row so names stay aligned whether or not a row has one. */
99
- #side-panel .panel-thumb {
100
- flex: 0 0 auto; width: 40px; height: 40px; border-radius: 6px; margin-left: auto;
112
+ /* Selected place (see selectPlace): the pin inverts, like the card it
113
+ matches. */
114
+ .marker-badge.active {
115
+ background: #fff !important; color: #0f172a !important;
116
+ transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.5);
117
+ }
118
+ /* Satellite / Traffic buttons under the zoom control (see buildLayerToggles). */
119
+ .mapboxgl-ctrl-group.layer-toggles button {
120
+ display: flex; align-items: center; justify-content: center; color: #333;
121
+ }
122
+ .mapboxgl-ctrl-group.layer-toggles button.active { color: #2563eb; background: #dbeafe; }
123
+ .mapboxgl-ctrl-group.layer-toggles button:focus:not(:focus-visible) { box-shadow: none; }
124
+ /* Every carousel place shows its name under the pin, with a white halo
125
+ instead of a box, the way map labels look. */
126
+ .marker-badge .marker-name {
127
+ position: absolute; top: 32px; left: 50%; transform: translateX(-50%);
128
+ white-space: nowrap; font-size: 11px; font-weight: 700; color: #0f172a;
129
+ text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff,
130
+ 0 0 3px #fff;
131
+ pointer-events: none;
132
+ }
133
+ /* A place pin (search results and other places in the carousel): a
134
+ white disc with the place's Maki icon, black when selected. Numbered
135
+ badges stay for markers whose number means something (visit order). */
136
+ .marker-badge.place {
137
+ width: 30px; height: 30px; background: #fff; color: #0f172a;
138
+ border: none; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
139
+ }
140
+ .marker-badge.place svg { width: 16px; height: 16px; display: block; }
141
+ .marker-badge.place.active {
142
+ background: #0f172a !important; color: #fff !important;
143
+ }
144
+ /* Results carousel. Sits above the Mapbox logo and attribution, which
145
+ must stay visible; the legend moves up above it (see showCarousel). */
146
+ #carousel { position: absolute; left: 0; right: 0; bottom: 36px; z-index: 10; }
147
+ #carousel .card-list {
148
+ list-style: none; margin: 0; padding: 4px 32px 6px;
149
+ display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
150
+ scroll-snap-type: x mandatory; scrollbar-width: none;
151
+ }
152
+ #carousel .card-list::-webkit-scrollbar { display: none; }
153
+ #carousel .card {
154
+ flex: 0 0 auto; width: 300px; max-width: calc(100vw - 64px); min-height: 84px;
155
+ scroll-snap-align: center;
156
+ display: flex; align-items: center; gap: 10px; padding: 10px;
157
+ background: var(--ui-surface); color: var(--ui-text);
158
+ border: 2px solid transparent; border-radius: 14px;
159
+ box-shadow: var(--ui-shadow); cursor: pointer;
160
+ }
161
+ #carousel .card.active { border-color: var(--ui-text); }
162
+ /* Added once a photo loads (see loadPanelPhoto), after the text in the
163
+ DOM but shown first. */
164
+ #carousel .card-thumb {
165
+ order: -1; flex: 0 0 auto; width: 64px; height: 64px; border-radius: 10px;
101
166
  background-size: cover; background-position: center;
102
167
  }
103
- /* Visit-order badge, matching the numbered map marker. */
104
- #side-panel .panel-badge {
105
- flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; margin-top: 1px;
168
+ /* Until a photo loads, or when the place has none: the place's icon
169
+ on a muted tile, so every card has a picture. */
170
+ #carousel .card-thumb.empty {
171
+ background: var(--ui-surface-muted); color: var(--ui-icon-muted);
172
+ display: flex; align-items: center; justify-content: center;
173
+ }
174
+ #carousel .card-thumb.empty svg { width: 28px; height: 28px; display: block; }
175
+ /* Matches the numbered pin. */
176
+ #carousel .card-badge {
177
+ flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%;
106
178
  background: #f97316; color: #1a0f04;
107
179
  display: flex; align-items: center; justify-content: center;
108
- font-size: 10px; font-weight: 700;
180
+ font-size: 11px; font-weight: 700;
109
181
  }
110
- #side-panel .panel-name { font-size: 12.5px; font-weight: 600; }
111
- #side-panel .panel-meta { font-size: 11px; color: #cbd5e1; margin-top: 2px; }
182
+ #carousel .card-body { flex: 1 1 auto; min-width: 0; }
183
+ #carousel .card-name {
184
+ font-size: 14px; font-weight: 600;
185
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
186
+ }
187
+ #carousel .card-meta, #carousel .card-extra {
188
+ font-size: 12px; color: var(--ui-text-muted); margin-top: 3px;
189
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
190
+ }
191
+ #carousel .hours-open { color: #15803d; font-weight: 600; }
192
+ #carousel .hours-closed { color: #b91c1c; font-weight: 600; }
193
+ :root[data-theme="dark"] #carousel .hours-open { color: #4ade80; }
194
+ :root[data-theme="dark"] #carousel .hours-closed { color: #f87171; }
112
195
  </style>
113
196
  </head>
114
197
  <body>
115
198
  <div id="map"></div>
116
199
  <div id="summary" style="display:none"></div>
117
- <div id="legend" style="display:none"></div>
118
- <div id="side-panel" style="display:none"></div>
200
+ <div id="legends">
201
+ <div id="legend" style="display:none"></div>
202
+ <div id="traffic-legend" style="display:none"></div>
203
+ </div>
204
+ <div id="carousel" style="display:none"></div>
119
205
  <div id="loading">Loading…</div>
120
206
  <div id="error" style="display:none"></div>
121
207
  ${initialDataScript}
@@ -129,9 +215,11 @@ ${initialDataScript}
129
215
  var errorEl = document.getElementById('error');
130
216
  var summaryEl = document.getElementById('summary');
131
217
  var legendEl = document.getElementById('legend');
132
- var panelEl = document.getElementById('side-panel');
133
- // Populated by buildSearchMiniPayload's marker tagging so the panel (click
134
- // -> flyTo) and Place Details enrichment can find a marker by mapbox_id
218
+ var trafficLegendEl = document.getElementById('traffic-legend');
219
+ var legendsEl = document.getElementById('legends');
220
+ var carouselEl = document.getElementById('carousel');
221
+ // Populated by buildSearchMiniPayload's marker tagging so the carousel (card
222
+ // <-> pin) and Place Details enrichment can find a marker by mapbox_id
135
223
  // without re-querying the map for it.
136
224
  var markersById = {};
137
225
 
@@ -148,6 +236,45 @@ ${initialDataScript}
148
236
  var trackedLayerIds = [];
149
237
  var trackedSourceIds = [];
150
238
  var trackedMarkers = [];
239
+ // Copies of the GeoJSON layers drawn so far. setStyle() drops every
240
+ // source and layer we added (DOM markers survive it), so a style switch
241
+ // from the Satellite button puts these back (see restoreOverlays).
242
+ var drawnLayers = [];
243
+ var currentBaseMapConfig = null;
244
+ var payloadLegend = [];
245
+ var trafficOn = false;
246
+ // Mapbox Traffic v1: live congestion per road segment, refreshed every few
247
+ // minutes. Served from api.mapbox.com, which the page's CSP already allows.
248
+ var TRAFFIC_ID = 'mapbox-live-traffic';
249
+ // Colors from the design's traffic legend (Fast to Slow).
250
+ var TRAFFIC_LEVELS = [
251
+ { value: 'low', label: 'Light traffic', color: '#00c94e' },
252
+ { value: 'moderate', label: 'Moderate traffic', color: '#ff9900' },
253
+ { value: 'heavy', label: 'Heavy traffic', color: '#ff2b3a' },
254
+ { value: 'severe', label: 'Severe traffic', color: '#8b0a18' }
255
+ ];
256
+ var TRAFFIC_MAIN_ROADS = [
257
+ 'motorway', 'motorway_link', 'trunk', 'trunk_link',
258
+ 'primary', 'secondary', 'tertiary', 'link'
259
+ ];
260
+ // 24px filled icons (a globe and a car, as in the design), sized to match
261
+ // the zoom buttons. The globe's lines are cut out in the button color.
262
+ var TOGGLE_ICONS = {
263
+ satellite: '<circle cx="12" cy="12" r="9.5"/>' +
264
+ '<path d="M2.5 12h19M12 2.5a14 14 0 0 1 0 19M12 2.5a14 14 0 0 0 0 19" ' +
265
+ 'fill="none" stroke="#fff" stroke-width="1.6"/>',
266
+ traffic: '<path fill-rule="evenodd" d="M5 11l1.6-4.4A2.4 2.4 0 0 1 8.9 5h6.2' +
267
+ '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' +
268
+ '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' +
269
+ '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' +
270
+ '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' +
271
+ 'a1.5 1.5 0 1 0 0-3z"/>'
272
+ };
273
+
274
+ // Set by the layer toggle control, which initMap() adds before the rest
275
+ // of this script has run.
276
+ var satelliteButton = null;
277
+ var trafficButton = null;
151
278
 
152
279
  // --- MCP App postMessage protocol -----------------------------------------
153
280
  var messageId = 0;
@@ -186,6 +313,7 @@ ${initialDataScript}
186
313
  }
187
314
 
188
315
  window.addEventListener('message', function(event) {
316
+ if (event.source !== window.parent) return;
189
317
  var message = event.data;
190
318
  if (!message || typeof message !== 'object') return;
191
319
  if (message.id !== undefined && pendingRequests.has(message.id)) {
@@ -199,12 +327,56 @@ ${initialDataScript}
199
327
  handleToolResult(message.params);
200
328
  }
201
329
  if (message.method === 'ui/notifications/host-context-changed' &&
202
- message.params && message.params.displayMode) {
203
- currentDisplayMode = message.params.displayMode;
204
- if (map) setTimeout(function() { map.resize(); }, 100);
330
+ message.params) {
331
+ // Partial updates: only the fields that changed are sent.
332
+ applyHostContext(message.params);
333
+ if (message.params.displayMode) {
334
+ currentDisplayMode = message.params.displayMode;
335
+ if (map) setTimeout(function() { map.resize(); }, 100);
336
+ }
205
337
  }
206
338
  });
207
339
 
340
+ // --- Light/dark theme -----------------------------------------------------
341
+ var rootEl = document.documentElement;
342
+ var hostTheme = null;
343
+
344
+ function applyTheme(theme) {
345
+ if ((theme !== 'light' && theme !== 'dark') || !rootEl) return;
346
+ rootEl.setAttribute('data-theme', theme);
347
+ }
348
+
349
+ // The host's theme wins over the system's; its style variables
350
+ // (--color-background-primary and so on) feed the --ui-* colors.
351
+ function applyHostContext(ctx) {
352
+ if (!ctx || typeof ctx !== 'object') return;
353
+ if (ctx.theme === 'light' || ctx.theme === 'dark') {
354
+ hostTheme = ctx.theme;
355
+ applyTheme(hostTheme);
356
+ }
357
+ var vars = ctx.styles && ctx.styles.variables;
358
+ if (vars && typeof vars === 'object' && rootEl && rootEl.style &&
359
+ typeof rootEl.style.setProperty === 'function') {
360
+ Object.keys(vars).forEach(function(name) {
361
+ if (/^--[a-z0-9-]+$/i.test(name) && typeof vars[name] === 'string') {
362
+ rootEl.style.setProperty(name, vars[name]);
363
+ }
364
+ });
365
+ }
366
+ }
367
+ window.__applyHostContext = applyHostContext;
368
+
369
+ var darkQuery = typeof window.matchMedia === 'function'
370
+ ? window.matchMedia('(prefers-color-scheme: dark)')
371
+ : null;
372
+ function followSystemTheme() {
373
+ if (!hostTheme && darkQuery) applyTheme(darkQuery.matches ? 'dark' : 'light');
374
+ }
375
+ followSystemTheme();
376
+ if (darkQuery && typeof darkQuery.addEventListener === 'function') {
377
+ darkQuery.addEventListener('change', followSystemTheme);
378
+ }
379
+
208
380
  // Set once a host answers ui/initialize, even with an error. A page from
209
381
  // export_map_html_tool framed by some other site never gets an answer.
210
382
  var hostAnswered = false;
@@ -213,8 +385,9 @@ ${initialDataScript}
213
385
  appCapabilities: {},
214
386
  appInfo: { name: 'Mapbox Map', version: '1.0.0' }
215
387
  }).then(
216
- function() {
388
+ function(result) {
217
389
  hostAnswered = true;
390
+ if (result) applyHostContext(result.hostContext);
218
391
  sendNotification('ui/notifications/initialized', {});
219
392
  },
220
393
  function() {
@@ -269,6 +442,7 @@ ${initialDataScript}
269
442
  }
270
443
  map = new mapboxgl.Map(mapOptions);
271
444
  map.addControl(new mapboxgl.NavigationControl(), 'top-left');
445
+ map.addControl(new LayerToggleControl(), 'top-left');
272
446
  map.on('load', function() {
273
447
  mapLoaded = true;
274
448
  if (pendingPayload) {
@@ -401,6 +575,7 @@ ${initialDataScript}
401
575
  } else {
402
576
  summaryEl.style.display = 'none';
403
577
  }
578
+ placeTopLeftControls();
404
579
  if (!map) { loadingEl.style.display = 'none'; return; }
405
580
  if (mapLoaded) render(payload);
406
581
  else pendingPayload = payload;
@@ -424,8 +599,13 @@ ${initialDataScript}
424
599
  trackedLayerIds = [];
425
600
  trackedSourceIds = [];
426
601
  trackedMarkers = [];
602
+ drawnLayers = [];
603
+ // A render without baseMapConfig must not bring back the last one (say,
604
+ // night mode) when the Satellite button switches style.
605
+ currentBaseMapConfig = null;
427
606
  markersById = {};
428
- panelEl.style.display = 'none';
607
+ carouselEl.style.display = 'none';
608
+ legendsEl.style.bottom = '';
429
609
  clearPanel();
430
610
  // Place Details responses still in flight belong to the old render.
431
611
  panelGeneration += 1;
@@ -461,10 +641,105 @@ ${initialDataScript}
461
641
  end: { color: '#ef4444' }
462
642
  };
463
643
 
644
+ // Maki icons (https://github.com/mapbox/maki, CC0), 15x15 path data, for
645
+ // the categories search results most often have. Anything else gets
646
+ // the plain marker.
647
+ var MAKI = {
648
+ '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',
649
+ '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',
650
+ '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',
651
+ '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',
652
+ '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',
653
+ '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',
654
+ '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',
655
+ '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',
656
+ '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',
657
+ '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',
658
+ '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',
659
+ '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',
660
+ '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',
661
+ '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',
662
+ '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',
663
+ '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',
664
+ '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',
665
+ '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',
666
+ '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',
667
+ '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',
668
+ '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',
669
+ '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',
670
+ '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'
671
+ };
672
+ // Category words (Search Box poi_category, or a caller's category) to
673
+ // Maki names, checked in order, so "pizza restaurant" is a pizza.
674
+ var CATEGORY_ICONS = [
675
+ ['pizza', 'restaurant-pizza'], ['coffee', 'cafe'], ['cafe', 'cafe'],
676
+ ['café', 'cafe'], ['bakery', 'bakery'], ['ice cream', 'ice-cream'],
677
+ ['brewery', 'beer'], ['beer', 'beer'], ['pub', 'beer'],
678
+ ['bar', 'bar'], ['nightclub', 'bar'], ['wine', 'bar'],
679
+ ['fast food', 'fast-food'], ['burger', 'fast-food'],
680
+ ['restaurant', 'restaurant'], ['food', 'restaurant'], ['diner', 'restaurant'],
681
+ ['hotel', 'lodging'], ['motel', 'lodging'], ['hostel', 'lodging'], ['lodging', 'lodging'],
682
+ ['grocery', 'grocery'], ['supermarket', 'grocery'], ['convenience', 'convenience'],
683
+ ['gas', 'fuel'], ['fuel', 'fuel'], ['parking', 'parking'],
684
+ ['hospital', 'hospital'], ['clinic', 'hospital'], ['pharmacy', 'pharmacy'],
685
+ ['park', 'park'], ['garden', 'park'], ['museum', 'museum'], ['gallery', 'museum'],
686
+ ['theater', 'theatre'], ['theatre', 'theatre'], ['cinema', 'cinema'], ['movie', 'cinema'],
687
+ ['bank', 'bank'], ['atm', 'bank'],
688
+ ['shop', 'shop'], ['store', 'shop'], ['boutique', 'shop'],
689
+ ['attraction', 'attraction'], ['landmark', 'attraction']
690
+ ];
691
+
692
+ // The Maki name for a marker or card: its own maki when we have that
693
+ // icon, else one matched from its category, else the plain marker.
694
+ function placeIcon(maki, category) {
695
+ if (typeof maki === 'string' && MAKI.hasOwnProperty(maki)) return maki;
696
+ if (typeof category === 'string') {
697
+ var words = ' ' + category.toLowerCase().split(/[^a-zà-ÿ]+/).join(' ') + ' ';
698
+ for (var i = 0; i < CATEGORY_ICONS.length; i++) {
699
+ if (words.indexOf(' ' + CATEGORY_ICONS[i][0] + ' ') !== -1) return CATEGORY_ICONS[i][1];
700
+ }
701
+ }
702
+ return 'marker';
703
+ }
704
+ window.__placeIcon = placeIcon;
705
+
706
+ // Only ever called with a key of MAKI, so the markup is all ours.
707
+ function iconSvg(name) {
708
+ var d = MAKI[MAKI.hasOwnProperty(name) ? name : 'marker'];
709
+ return '<svg viewBox="0 0 15 15" aria-hidden="true"><path fill="currentColor" d="' + d + '"/></svg>';
710
+ }
711
+
712
+ function buildPlaceElement(icon, color) {
713
+ var el = document.createElement('div');
714
+ el.className = 'marker-badge place';
715
+ // A caller's color (gray for "other results", say) tints the icon.
716
+ if (color) el.style.color = color;
717
+ el.innerHTML = iconSvg(icon);
718
+ return el;
719
+ }
720
+
721
+ // Dark or white, whichever reads better on the badge color (WCAG
722
+ // contrast), so numbers stay legible on orange, green or gray pins.
723
+ var BADGE_DARK_TEXT = '#1a0f04';
724
+ function badgeTextColor(color) {
725
+ var hex = typeof color === 'string' ? color.replace('#', '') : '';
726
+ if (hex.length === 3) hex = hex.split('').map(function(c) { return c + c; }).join('');
727
+ if (!/^[0-9a-f]{6}/i.test(hex)) return '#fff';
728
+ var lum = [0, 2, 4].map(function(i) {
729
+ var c = parseInt(hex.slice(i, i + 2), 16) / 255;
730
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
731
+ });
732
+ var l = 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2];
733
+ // Against white (L=1) vs. against the dark text (L about 0.0055).
734
+ return (1.05 / (l + 0.05)) >= ((l + 0.05) / 0.0555) ? '#fff' : BADGE_DARK_TEXT;
735
+ }
736
+ window.__badgeTextColor = badgeTextColor;
737
+
464
738
  function buildBadgeElement(label, color) {
465
739
  var el = document.createElement('div');
466
740
  el.className = 'marker-badge';
467
741
  el.style.background = color;
742
+ el.style.color = badgeTextColor(color);
468
743
  el.textContent = label;
469
744
  return el;
470
745
  }
@@ -476,7 +751,24 @@ ${initialDataScript}
476
751
  if (!layer || !layer.id || !layer.data) return;
477
752
  map.addSource(layer.id, { type: 'geojson', data: layer.data });
478
753
  trackedSourceIds.push(layer.id);
754
+ map.addLayer(layerDefinition(layer));
755
+ trackedLayerIds.push(layer.id);
756
+ drawnLayers.push({
757
+ id: layer.id, type: layer.type, data: layer.data,
758
+ paint: layer.paint, layout: layer.layout, slot: layer.slot
759
+ });
760
+
761
+ var feature = layer.data;
762
+ if (feature.type === 'FeatureCollection' && Array.isArray(feature.features)) {
763
+ feature.features.forEach(function(f) {
764
+ if (f && f.geometry) bbox.extend(f.geometry.coordinates);
765
+ });
766
+ } else if (feature.geometry) {
767
+ bbox.extend(feature.geometry.coordinates);
768
+ }
769
+ }
479
770
 
771
+ function layerDefinition(layer) {
480
772
  var def = {
481
773
  id: layer.id,
482
774
  type: layer.type,
@@ -485,17 +777,176 @@ ${initialDataScript}
485
777
  if (layer.paint) def.paint = layer.paint;
486
778
  if (layer.layout) def.layout = layer.layout;
487
779
  if (layer.slot) def.slot = layer.slot;
488
- map.addLayer(def);
489
- trackedLayerIds.push(layer.id);
780
+ return def;
781
+ }
490
782
 
491
- var feature = layer.data;
492
- if (feature.type === 'FeatureCollection' && Array.isArray(feature.features)) {
493
- feature.features.forEach(function(f) {
494
- if (f && f.geometry) bbox.extend(f.geometry.coordinates);
783
+ // --- Satellite / Traffic toggles -----------------------------------------
784
+ function addTrafficLayer() {
785
+ if (!map.getSource(TRAFFIC_ID)) {
786
+ map.addSource(TRAFFIC_ID, {
787
+ type: 'vector',
788
+ url: 'mapbox://mapbox.mapbox-traffic-v1'
495
789
  });
496
- } else if (feature.geometry) {
497
- bbox.extend(feature.geometry.coordinates);
498
790
  }
791
+ if (map.getLayer(TRAFFIC_ID)) return;
792
+ var colors = ['match', ['get', 'congestion']];
793
+ TRAFFIC_LEVELS.forEach(function(level) { colors.push(level.value, level.color); });
794
+ colors.push('rgba(0,0,0,0)');
795
+ map.addLayer({
796
+ id: TRAFFIC_ID,
797
+ type: 'line',
798
+ source: TRAFFIC_ID,
799
+ 'source-layer': 'traffic',
800
+ // Above land and water, below roads' labels and anything we draw.
801
+ slot: 'middle',
802
+ // Light traffic on every side street turns the whole map green, so
803
+ // show it only on main roads; any slowdown shows on every road.
804
+ filter: ['any',
805
+ ['!=', ['get', 'congestion'], 'low'],
806
+ ['match', ['get', 'class'], TRAFFIC_MAIN_ROADS, true, false]
807
+ ],
808
+ layout: { 'line-join': 'round', 'line-cap': 'round' },
809
+ paint: {
810
+ 'line-color': colors,
811
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1, 15, 2, 18, 4],
812
+ // Each direction is its own line; shift it to its side of the road.
813
+ 'line-offset': ['interpolate', ['linear'], ['zoom'], 12, 0.5, 15, 1.5, 18, 3],
814
+ 'line-opacity': 0.9
815
+ }
816
+ });
817
+ }
818
+
819
+ function removeTrafficLayer() {
820
+ if (map.getLayer(TRAFFIC_ID)) map.removeLayer(TRAFFIC_ID);
821
+ if (map.getSource(TRAFFIC_ID)) map.removeSource(TRAFFIC_ID);
822
+ }
823
+
824
+ function setTraffic(on) {
825
+ trafficOn = on;
826
+ if (map && mapLoaded) {
827
+ if (on) addTrafficLayer();
828
+ else removeTrafficLayer();
829
+ }
830
+ renderLegend();
831
+ syncToggleButtons();
832
+ }
833
+
834
+ // Switches the base style, then runs afterLoad once the new style is up.
835
+ function switchBaseStyle(styleId, afterLoad) {
836
+ currentBaseStyleId = styleId;
837
+ syncToggleButtons();
838
+ map.setStyle('mapbox://styles/mapbox/' + styleId);
839
+ map.once('style.load', afterLoad);
840
+ }
841
+
842
+ // After a style switch from the Satellite button: put back what setStyle
843
+ // dropped, without refitting the camera the user may have moved.
844
+ function restoreOverlays() {
845
+ applyBaseMapConfig(currentBaseMapConfig);
846
+ drawnLayers.forEach(function(layer) {
847
+ if (!map.getSource(layer.id)) {
848
+ map.addSource(layer.id, { type: 'geojson', data: layer.data });
849
+ }
850
+ if (!map.getLayer(layer.id)) map.addLayer(layerDefinition(layer));
851
+ });
852
+ if (trafficOn) addTrafficLayer();
853
+ }
854
+
855
+ function toggleSatellite() {
856
+ if (!map || !mapLoaded) return;
857
+ switchBaseStyle(
858
+ currentBaseStyleId === 'standard-satellite' ? 'standard' : 'standard-satellite',
859
+ restoreOverlays
860
+ );
861
+ }
862
+
863
+ // A GL JS control (onAdd returns its element). Built by a returned object
864
+ // rather than prototype methods because initMap() runs before the rest
865
+ // of this script, and addControl calls onAdd right away.
866
+ function LayerToggleControl() {
867
+ return { onAdd: buildLayerToggles, onRemove: function() {} };
868
+ }
869
+
870
+ function buildLayerToggles() {
871
+ var container = document.createElement('div');
872
+ container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group layer-toggles';
873
+ satelliteButton = buildToggleButton('Satellite view', TOGGLE_ICONS.satellite, toggleSatellite);
874
+ trafficButton = buildToggleButton('Live traffic', TOGGLE_ICONS.traffic, function() {
875
+ setTraffic(!trafficOn);
876
+ });
877
+ container.appendChild(satelliteButton);
878
+ container.appendChild(trafficButton);
879
+ syncToggleButtons();
880
+ return container;
881
+ }
882
+
883
+ function buildToggleButton(label, icon, onClick) {
884
+ var button = document.createElement('button');
885
+ button.type = 'button';
886
+ button.title = label;
887
+ if (typeof button.setAttribute === 'function') button.setAttribute('aria-label', label);
888
+ button.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" ' +
889
+ 'fill="currentColor" aria-hidden="true">' + icon + '</svg>';
890
+ button.addEventListener('click', onClick);
891
+ return button;
892
+ }
893
+
894
+ function syncToggleButtons() {
895
+ setPressed(satelliteButton, currentBaseStyleId === 'standard-satellite');
896
+ setPressed(trafficButton, trafficOn);
897
+ }
898
+
899
+ function setPressed(button, pressed) {
900
+ if (!button) return;
901
+ button.className = pressed ? 'active' : '';
902
+ if (typeof button.setAttribute === 'function') {
903
+ button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
904
+ }
905
+ }
906
+
907
+ // The summary chip covers GL JS's top-left control corner, so move the
908
+ // zoom and layer buttons below it while it's shown.
909
+ function placeTopLeftControls() {
910
+ if (!map || typeof map.getContainer !== 'function') return;
911
+ var container = map.getContainer();
912
+ var corner = container && typeof container.querySelector === 'function'
913
+ ? container.querySelector('.mapboxgl-ctrl-top-left')
914
+ : null;
915
+ if (!corner) return;
916
+ corner.style.top = summaryEl.style.display === 'block'
917
+ ? (summaryEl.offsetHeight + 8) + 'px'
918
+ : '';
919
+ }
920
+
921
+ // The payload's legend rows, and below them the traffic scale while
922
+ // traffic is on.
923
+ function renderLegend() {
924
+ renderTrafficLegend();
925
+ var rows = payloadLegend;
926
+ if (rows.length === 0) {
927
+ legendEl.style.display = 'none';
928
+ return;
929
+ }
930
+ legendEl.innerHTML = rows.map(function(row) {
931
+ var op = (typeof row.opacity === 'number') ? row.opacity : 1;
932
+ return '<div class="row"><span class="swatch" style="background:' +
933
+ escapeAttr(row.color) + ';opacity:' + op + '"></span>' +
934
+ escapeText(row.label) + '</div>';
935
+ }).join('');
936
+ legendEl.style.display = 'block';
937
+ }
938
+
939
+ function renderTrafficLegend() {
940
+ if (!trafficOn) {
941
+ trafficLegendEl.style.display = 'none';
942
+ return;
943
+ }
944
+ trafficLegendEl.innerHTML = '<span>Fast</span><span class="bar">' +
945
+ TRAFFIC_LEVELS.map(function(level) {
946
+ return '<span title="' + level.label + '" style="background:' +
947
+ level.color + '"></span>';
948
+ }).join('') + '</span><span>Slow</span>';
949
+ trafficLegendEl.style.display = 'flex';
499
950
  }
500
951
 
501
952
  function addOneMarker(m, bbox) {
@@ -506,38 +957,58 @@ ${initialDataScript}
506
957
  var color = m.color ||
507
958
  (STYLE_DEFAULTS[style] && STYLE_DEFAULTS[style].color) || '#3b82f6';
508
959
  var marker;
509
- if (style === 'numbered' || style === 'start' || style === 'end') {
960
+ if (style === 'place') {
961
+ marker = new mapboxgl.Marker({ element: buildPlaceElement(placeIcon(m.maki, m.category), m.color) });
962
+ } else if (style === 'numbered' || style === 'start' || style === 'end') {
510
963
  var label = m.label || (style === 'start' ? 'S' : style === 'end' ? 'E' : '');
511
964
  marker = new mapboxgl.Marker({ element: buildBadgeElement(label, color) });
512
965
  } else {
513
966
  marker = new mapboxgl.Marker({ color: color });
514
967
  }
515
968
  marker.setLngLat(m.coordinates);
516
- if (m.popup) marker.setPopup(new mapboxgl.Popup().setText(m.popup));
969
+ // A place with an id gets a carousel card instead of a popup: clicking
970
+ // its pin selects the card (see selectPlace).
971
+ if (m.popup && !m.id) marker.setPopup(new mapboxgl.Popup().setText(m.popup));
517
972
  marker.addTo(map);
518
973
  trackedMarkers.push(marker);
519
974
  // Tagged by buildSearchMiniPayload with the result's mapbox_id so the
520
- // side panel (click-to-flyTo) and Place Details enrichment can look the
521
- // marker back up later without walking trackedMarkers.
522
- if (m.id) markersById[m.id] = { marker: marker, popupText: m.popup || '' };
975
+ // carousel and Place Details enrichment can look the marker back up
976
+ // later without walking trackedMarkers.
977
+ if (m.id) {
978
+ var badgeEl = typeof marker.getElement === 'function' ? marker.getElement() : null;
979
+ markersById[m.id] = { marker: marker, el: badgeEl, color: color };
980
+ if (badgeEl && m.name) {
981
+ var nameEl = document.createElement('span');
982
+ nameEl.className = 'marker-name';
983
+ nameEl.textContent = m.name;
984
+ badgeEl.appendChild(nameEl);
985
+ }
986
+ if (badgeEl && typeof badgeEl.addEventListener === 'function') {
987
+ var placeId = m.id;
988
+ badgeEl.addEventListener('click', function(e) {
989
+ if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
990
+ selectPlace(placeId, { scrollCard: true, panTo: true });
991
+ });
992
+ }
993
+ }
523
994
  bbox.extend(m.coordinates);
524
995
  }
525
996
 
526
- // Guarantees a marker that opts into the side panel (has .id) also gets
527
- // a visible number on the map matching its panel row, unless the caller
997
+ // Guarantees a marker that opts into the carousel (has .id) also gets
998
+ // a visible number on the map matching its card, unless the caller
528
999
  // already gave it an explicit label. Without this, a caller that builds
529
1000
  // plain unlabeled pins for POI markers (observed live: Claude did this
530
1001
  // for inline markers, leaving the map showing generic blue pins with no
531
- // way to tell which panel row is which) breaks the whole point of the
532
- // panel numbering. Mutates markers in place, so it must run before
1002
+ // way to tell which card is which) breaks the whole point of the
1003
+ // numbering. Mutates markers in place, so it must run before
533
1004
  // addOneMarker draws them.
534
1005
  //
535
1006
  // Numbering runs across the whole render, not per call: a render can
536
1007
  // draw inline markers and then merge in self-fetched search results, and
537
1008
  // each set arrives numbered from 1. The first set keeps the caller's
538
1009
  // numbers when they're unique (a visit order, say); a later set, or one
539
- // with repeated numbers, is renumbered to continue the panel's count.
540
- // Letter labels ("A") are kept as they are. A place already in the panel
1010
+ // with repeated numbers, is renumbered to continue the carousel's count.
1011
+ // Letter labels ("A") are kept as they are. A place already in the carousel
541
1012
  // this render is dropped rather than drawn and listed twice. Returns the
542
1013
  // markers to draw.
543
1014
  function applyPanelNumbering(markers) {
@@ -564,12 +1035,17 @@ ${initialDataScript}
564
1035
  fresh.forEach(function(m) {
565
1036
  if (!m || !m.id) return;
566
1037
  if (m.label && (keep || !isWholeNumber(m.label))) return;
1038
+ // A place with no number of its own gets an icon pin, not a number:
1039
+ // the selected pin and card light up together, which is enough to
1040
+ // tell them apart.
1041
+ if (!m.label && m.style !== 'start' && m.style !== 'end') {
1042
+ m.style = 'place';
1043
+ return;
1044
+ }
1045
+ // Otherwise a numbered pin, or a start/end pin, takes the next number.
567
1046
  panelCount += 1;
568
1047
  var n = String(panelCount);
569
- if (!m.label) {
570
- if (m.style !== 'start' && m.style !== 'end') m.style = 'numbered';
571
- if (!m.color) m.color = '#f97316';
572
- } else if (typeof m.popup === 'string' && m.popup.indexOf(m.label + '. ') === 0) {
1048
+ if (m.label && typeof m.popup === 'string' && m.popup.indexOf(m.label + '. ') === 0) {
573
1049
  // Search popups start with the old number ("3. Name").
574
1050
  m.popup = n + '. ' + m.popup.slice(String(m.label).length + 2);
575
1051
  }
@@ -584,26 +1060,33 @@ ${initialDataScript}
584
1060
  return n > 0 && String(n) === String(label);
585
1061
  }
586
1062
 
587
- // Bottom padding that keeps fitted data out from under the legend
588
- // (bottom-left), unless the map is too short to spare the room.
1063
+ // Bottom padding that keeps fitted data out from under the carousel and
1064
+ // the legends above it, unless the map is too short to spare the room.
589
1065
  function bottomPadding() {
590
- if (legendEl.style.display !== 'block') return 50;
591
- var bottom = (legendEl.offsetHeight || 0) + 24;
1066
+ var bottom = 0;
1067
+ if (carouselEl.style.display === 'block') bottom = carouselTop() + 12;
1068
+ if (legendEl.style.display === 'block' ||
1069
+ trafficLegendEl.style.display === 'flex') {
1070
+ bottom = Math.max(bottom, legendTop() + 12);
1071
+ }
592
1072
  var el = document.getElementById('map');
593
1073
  var height = (el && el.clientHeight) || window.innerHeight || 0;
594
- if (bottom <= 50 || (height && height - bottom - 70 < 160)) return 50;
1074
+ if (bottom <= 50 || (height && height - bottom - 70 < 120)) return 50;
595
1075
  return bottom;
596
1076
  }
597
1077
 
598
- // Right padding that keeps fitted data out from under an open panel. On
599
- // a narrow map there's no room beside it, so fit as if it weren't there
600
- // (the panel starts collapsed there anyway; see appendPanelRows).
601
- function rightPadding() {
602
- if (panelEl.style.display !== 'flex' || panelCollapsed) return 30;
603
- var right = (panelEl.offsetWidth || 280) + 24;
604
- var mapWidth = mapContainerWidth();
605
- if (mapWidth && mapWidth - right < 240) return 30;
606
- return right;
1078
+ // Distance from the bottom of the map to the top of the carousel. 36px
1079
+ // keeps it clear of the Mapbox logo and attribution.
1080
+ var CAROUSEL_BOTTOM = 36;
1081
+ function carouselTop() {
1082
+ return CAROUSEL_BOTTOM + (carouselEl.offsetHeight || 100);
1083
+ }
1084
+
1085
+ // The legends stack sits 40px up (above the Mapbox logo) until the
1086
+ // carousel pushes it higher.
1087
+ function legendTop() {
1088
+ var bottom = parseInt(legendsEl.style.bottom, 10) || 40;
1089
+ return bottom + (legendsEl.offsetHeight || 0);
607
1090
  }
608
1091
 
609
1092
  function mapContainerWidth() {
@@ -617,7 +1100,7 @@ ${initialDataScript}
617
1100
  setTimeout(function() {
618
1101
  map.resize();
619
1102
  map.fitBounds(bounds, {
620
- padding: { top: 70, bottom: bottomPadding(), left: 30, right: rightPadding() },
1103
+ padding: { top: 70, bottom: bottomPadding(), left: 30, right: 30 },
621
1104
  duration: 600
622
1105
  });
623
1106
  }, 60);
@@ -627,10 +1110,30 @@ ${initialDataScript}
627
1110
  // the map (does NOT teardown first) and refits to include the new
628
1111
  // content. Used once a self-fetched layer (e.g. a directions route)
629
1112
  // resolves after the initial render.
1113
+ // Search Box errors carry an object, {"message":{"error":"Invalid
1114
+ // proximity provided..."}}, where the other APIs carry a string; showing
1115
+ // the object printed "[object Object]".
1116
+ function apiErrorText(body, fallback) {
1117
+ var m = body && body.message;
1118
+ if (m && typeof m === 'object') m = m.error || m.message;
1119
+ return typeof m === 'string' && m ? m : fallback;
1120
+ }
1121
+
1122
+ // Self-fetch layers use fixed ids, so two refs of the same kind in one
1123
+ // render (search results for SFO and for Palo Alto) would both add
1124
+ // "selffetch-search-results" and the second addSource() throws.
1125
+ function unusedLayerId(id) {
1126
+ if (!map.getSource(id) && !map.getLayer(id)) return id;
1127
+ var n = 2;
1128
+ while (map.getSource(id + '-' + n) || map.getLayer(id + '-' + n)) n++;
1129
+ return id + '-' + n;
1130
+ }
1131
+
630
1132
  function mergeAdditionalPayload(payload) {
631
1133
  if (!map) return;
632
1134
  var bbox = bboxAccumulator();
633
1135
  (Array.isArray(payload.layers) ? payload.layers : []).forEach(function(layer) {
1136
+ if (layer && layer.id) layer = Object.assign({}, layer, { id: unusedLayerId(layer.id) });
634
1137
  addOneLayer(layer, bbox);
635
1138
  });
636
1139
  var markers = applyPanelNumbering(payload.markers);
@@ -638,14 +1141,16 @@ ${initialDataScript}
638
1141
  if (payload.summary && summaryEl.style.display === 'none') {
639
1142
  summaryEl.textContent = payload.summary;
640
1143
  summaryEl.style.display = 'block';
1144
+ placeTopLeftControls();
641
1145
  }
642
- maybeShowSidePanel(markers, payload.summary);
1146
+ maybeShowSidePanel(markers);
643
1147
  var bounds = bbox.bounds();
644
1148
  if (bounds) fitToBounds(bounds);
645
1149
  }
646
1150
 
647
1151
  function applyBaseMapConfig(baseMapConfig) {
648
1152
  if (!baseMapConfig || typeof baseMapConfig !== 'object') return;
1153
+ currentBaseMapConfig = baseMapConfig;
649
1154
  if (typeof map.setConfigProperty !== 'function') return;
650
1155
  Object.keys(baseMapConfig).forEach(function(key) {
651
1156
  try { map.setConfigProperty('basemap', key, baseMapConfig[key]); } catch (_) { /* not a Standard-based style */ }
@@ -664,9 +1169,7 @@ ${initialDataScript}
664
1169
  ? 'standard-satellite'
665
1170
  : (payload.baseStyle === 'standard' ? 'standard' : null);
666
1171
  if (desiredBaseStyleId && desiredBaseStyleId !== currentBaseStyleId) {
667
- currentBaseStyleId = desiredBaseStyleId;
668
- map.setStyle('mapbox://styles/mapbox/' + desiredBaseStyleId);
669
- map.once('style.load', function() { renderBody(payload); });
1172
+ switchBaseStyle(desiredBaseStyleId, function() { renderBody(payload); });
670
1173
  return;
671
1174
  }
672
1175
  renderBody(payload);
@@ -684,21 +1187,14 @@ ${initialDataScript}
684
1187
  // Covers both inline markers (a caller-composed payload) and self-fetch
685
1188
  // markers (merged in later via mergeAdditionalPayload) — see
686
1189
  // maybeShowSidePanel below. Whichever path produced id-bearing POI
687
- // markers gets the same results panel.
688
- maybeShowSidePanel(markers, payload.summary);
689
-
690
- // Legend
691
- if (Array.isArray(payload.legend) && payload.legend.length > 0) {
692
- legendEl.innerHTML = payload.legend.map(function(row) {
693
- var op = (typeof row.opacity === 'number') ? row.opacity : 1;
694
- return '<div class="row"><span class="swatch" style="background:' +
695
- escapeAttr(row.color) + ';opacity:' + op + '"></span>' +
696
- escapeText(row.label) + '</div>';
697
- }).join('');
698
- legendEl.style.display = 'block';
699
- } else {
700
- legendEl.style.display = 'none';
701
- }
1190
+ // markers gets the same results carousel.
1191
+ maybeShowSidePanel(markers);
1192
+
1193
+ payloadLegend = Array.isArray(payload.legend) ? payload.legend : [];
1194
+ // showTraffic set by the caller wins; left out, keep whatever the user
1195
+ // picked with the Traffic button (a style switch dropped the layer, so
1196
+ // setTraffic puts it back).
1197
+ setTraffic(typeof payload.showTraffic === 'boolean' ? payload.showTraffic : trafficOn);
702
1198
 
703
1199
  loadingEl.style.display = 'none';
704
1200
  requestSizeToFit();
@@ -730,6 +1226,9 @@ ${initialDataScript}
730
1226
  if (!data) return;
731
1227
  var src = map.getSource(payload.defer.layerId);
732
1228
  if (src && typeof src.setData === 'function') src.setData(data);
1229
+ drawnLayers.forEach(function(layer) {
1230
+ if (layer.id === payload.defer.layerId) layer.data = data;
1231
+ });
733
1232
  },
734
1233
  function() { /* ignore — keep what we rendered inline */ }
735
1234
  );
@@ -962,10 +1461,7 @@ ${initialDataScript}
962
1461
  .json()
963
1462
  .catch(function() { return null; })
964
1463
  .then(function(body) {
965
- var msg =
966
- body && body.message
967
- ? body.message
968
- : 'Directions API error (' + res.status + ')';
1464
+ var msg = apiErrorText(body, 'Directions API error (' + res.status + ')');
969
1465
  throw new Error(msg);
970
1466
  });
971
1467
  }
@@ -1137,10 +1633,7 @@ ${initialDataScript}
1137
1633
  .json()
1138
1634
  .catch(function() { return null; })
1139
1635
  .then(function(body) {
1140
- var msg =
1141
- body && body.message
1142
- ? body.message
1143
- : 'Isochrone API error (' + res.status + ')';
1636
+ var msg = apiErrorText(body, 'Isochrone API error (' + res.status + ')');
1144
1637
  throw new Error(msg);
1145
1638
  });
1146
1639
  }
@@ -1276,10 +1769,7 @@ ${initialDataScript}
1276
1769
  .json()
1277
1770
  .catch(function() { return null; })
1278
1771
  .then(function(body) {
1279
- var msg =
1280
- body && body.message
1281
- ? body.message
1282
- : 'Map Matching API error (' + res.status + ')';
1772
+ var msg = apiErrorText(body, 'Map Matching API error (' + res.status + ')');
1283
1773
  throw new Error(msg);
1284
1774
  });
1285
1775
  }
@@ -1389,29 +1879,31 @@ ${initialDataScript}
1389
1879
  var features = [];
1390
1880
  points.forEach(function(f, i) {
1391
1881
  var props = f.properties || {};
1392
- var popupParts = [(i + 1) + '. ' + (props.name || 'Result')];
1882
+ var popupParts = [props.name || 'Result'];
1393
1883
  var addr = props.full_address || props.place_formatted;
1394
1884
  if (addr) popupParts.push(addr);
1395
- if (typeof props.distance === 'number') {
1396
- popupParts.push(Math.round(props.distance) + ' m');
1885
+ // Without a proximity the API measures distance from the caller's IP,
1886
+ // which on a hosted server is the user's browser or nowhere useful.
1887
+ var distance = proximity && typeof props.distance === 'number' ? props.distance : undefined;
1888
+ if (distance !== undefined) {
1889
+ popupParts.push(Math.round(distance) + ' m');
1397
1890
  }
1398
1891
  var coords = f.geometry.coordinates;
1399
1892
  // id/name/category/distanceMeters are the same generic POI fields an
1400
1893
  // inline (hand-composed) marker can carry — see maybeShowSidePanel,
1401
- // which builds the results panel from either source identically.
1402
- // mapbox_id is missing on the rare result that lacks one; the panel
1894
+ // which builds the results carousel from either source identically.
1895
+ // mapbox_id is missing on the rare result that lacks one; the carousel
1403
1896
  // just skips that row rather than show an unclickable/unenrichable
1404
1897
  // entry.
1405
1898
  markers.push({
1406
1899
  id: props.mapbox_id,
1407
1900
  coordinates: coords,
1408
- style: 'numbered',
1409
- label: String(i + 1),
1410
- color: '#f97316',
1901
+ style: 'place',
1902
+ maki: props.maki,
1411
1903
  popup: popupParts.join(' — '),
1412
1904
  name: props.name || ('Result ' + (i + 1)),
1413
1905
  category: Array.isArray(props.poi_category) ? props.poi_category[0] : undefined,
1414
- distanceMeters: typeof props.distance === 'number' ? props.distance : undefined
1906
+ distanceMeters: distance
1415
1907
  });
1416
1908
  features.push({
1417
1909
  type: 'Feature',
@@ -1431,11 +1923,11 @@ ${initialDataScript}
1431
1923
  data: { type: 'FeatureCollection', features: features },
1432
1924
  paint: {
1433
1925
  'circle-radius': 6,
1434
- 'circle-color': '#f97316',
1435
- 'circle-opacity': 0.15,
1926
+ 'circle-color': '#0f172a',
1927
+ 'circle-opacity': 0.1,
1436
1928
  'circle-stroke-width': 1,
1437
- 'circle-stroke-color': '#f97316',
1438
- 'circle-stroke-opacity': 0.4
1929
+ 'circle-stroke-color': '#0f172a',
1930
+ 'circle-stroke-opacity': 0.25
1439
1931
  }
1440
1932
  }
1441
1933
  ] : [];
@@ -1443,27 +1935,27 @@ ${initialDataScript}
1443
1935
  return { summary: summary, layers: layers, markers: markers };
1444
1936
  }
1445
1937
 
1446
- // --- Side panel: result list + Place Details enrichment ----------------
1938
+ // --- Results carousel: one card per place + Place Details enrichment ---
1447
1939
  // Triggered generically by maybeShowSidePanel for ANY marker set that
1448
1940
  // includes id-bearing entries — self-fetch search/category-search
1449
1941
  // results (via mergeAdditionalPayload) and hand-composed inline markers
1450
1942
  // from render_map_tool's own "markers" input (via renderBody) both funnel
1451
1943
  // through the same path. Renders the initial (Search Box-only, or
1452
- // whatever the caller already provided) list immediately so the panel
1453
- // never blocks on the slower enrichment call. sourceLabel is plain text
1454
- // shown as the panel heading (e.g. the query, category, or the payload's
1455
- // own summary).
1944
+ // whatever the caller already provided) cards immediately so the
1945
+ // carousel never blocks on the slower enrichment call.
1456
1946
  function derivePanelItems(markers) {
1457
1947
  if (!Array.isArray(markers)) return [];
1458
1948
  var items = [];
1459
1949
  markers.forEach(function(m) {
1460
1950
  if (!m || !m.id) return;
1461
- // The pin's own label, so a row reads the same as its pin ("A" too).
1951
+ // The pin's own label, so a card reads the same as its pin ("A" too).
1952
+ // A place pin has none, and neither does its card.
1462
1953
  var badge = isWholeNumber(m.label) ? parseInt(m.label, 10)
1463
- : (m.label ? String(m.label) : items.length + 1);
1954
+ : (m.label ? String(m.label) : null);
1464
1955
  items.push({
1465
1956
  id: m.id,
1466
1957
  number: badge,
1958
+ icon: placeIcon(m.maki, m.category),
1467
1959
  name: m.name || ('Result ' + (items.length + 1)),
1468
1960
  category: m.category,
1469
1961
  distanceMeters: typeof m.distanceMeters === 'number' ? m.distanceMeters : undefined,
@@ -1475,16 +1967,16 @@ ${initialDataScript}
1475
1967
  // Exposed so tests can exercise the pure derivation logic without a DOM.
1476
1968
  window.__derivePanelItems = derivePanelItems;
1477
1969
 
1478
- // Adds rows for places not yet in this render's panel and enriches just
1479
- // those. A response is applied only if no newer render has started since
1480
- // (panelGeneration), so a slow lookup can't write into a newer panel.
1481
- function maybeShowSidePanel(markers, summary) {
1970
+ // Adds cards for places not yet in this render's carousel and enriches
1971
+ // just those. A response is applied only if no newer render has started
1972
+ // since (panelGeneration), so a slow lookup can't write into newer cards.
1973
+ function maybeShowSidePanel(markers) {
1482
1974
  var items = derivePanelItems(markers).filter(function(it) {
1483
1975
  return !panelIds[it.id];
1484
1976
  });
1485
1977
  if (items.length === 0) return;
1486
1978
  items.forEach(function(it) { panelIds[it.id] = true; });
1487
- appendPanelRows(items, summary || 'Results');
1979
+ appendCards(items);
1488
1980
  var generation = panelGeneration;
1489
1981
  fetchPlaceDetailsBatch(
1490
1982
  items.map(function(it) { return it.id; }),
@@ -1495,109 +1987,334 @@ ${initialDataScript}
1495
1987
  });
1496
1988
  }
1497
1989
 
1498
- // Keyed by mapbox_id -> { li, meta } elements, so enrichSidePanel can
1499
- // update a row directly once Place Details resolves, without re-parsing
1500
- // panelEl's markup back into a queryable DOM (innerHTML + querySelector
1990
+ // Keyed by mapbox_id -> { li, meta, body } elements, so enrichSidePanel
1991
+ // can update a card directly once Place Details resolves, without
1992
+ // re-parsing markup back into a queryable DOM (innerHTML + querySelector
1501
1993
  // round-trips don't work in every MCP Apps host's iframe sandbox, and
1502
1994
  // building real nodes up front is simpler to reason about either way).
1503
- // Reset by clearPanel, same lifecycle as the panel itself.
1995
+ // Reset by clearPanel, same lifecycle as the carousel itself.
1504
1996
  var panelRowsById = {};
1505
1997
  // Bumped by teardown(), so responses from an earlier render are dropped.
1506
1998
  var panelGeneration = 0;
1507
1999
  // Id-bearing markers numbered so far this render, and the ids listed.
1508
2000
  var panelCount = 0;
1509
2001
  var panelIds = {};
1510
- // The viewer's collapse choice, kept across renders; null until the first
1511
- // panel decides a default from the map's width.
1512
- var panelCollapsed = null;
1513
- var panelListEl = null;
2002
+ var cardListEl = null;
2003
+ var cardOrder = [];
2004
+ var activePlaceId = null;
2005
+
2006
+ // classList when there is one: GL JS adds its own classes to a marker's
2007
+ // element, which assigning className would wipe out.
2008
+ function setActiveClass(el, on) {
2009
+ if (!el) return;
2010
+ if (el.classList && typeof el.classList.toggle === 'function') {
2011
+ el.classList.toggle('active', on);
2012
+ return;
2013
+ }
2014
+ var names = String(el.className || '').split(' ').filter(function(c) {
2015
+ return c && c !== 'active';
2016
+ });
2017
+ if (on) names.push('active');
2018
+ el.className = names.join(' ');
2019
+ }
2020
+
2021
+ // Highlights one place: its card gets a border and its pin inverts and
2022
+ // shows the name. scrollCard brings the card into view (a pin was
2023
+ // clicked); panTo moves the map when the pin is hidden (a card was
2024
+ // picked).
2025
+ function selectPlace(id, opts) {
2026
+ var row = panelRowsById[id];
2027
+ if (!row) return;
2028
+ opts = opts || {};
2029
+ if (activePlaceId !== id) {
2030
+ if (activePlaceId) {
2031
+ var prevRow = panelRowsById[activePlaceId];
2032
+ var prevPin = markersById[activePlaceId];
2033
+ if (prevRow) setActiveClass(prevRow.li, false);
2034
+ if (prevPin && prevPin.el) {
2035
+ setActiveClass(prevPin.el, false);
2036
+ prevPin.el.style.zIndex = '';
2037
+ }
2038
+ }
2039
+ activePlaceId = id;
2040
+ setActiveClass(row.li, true);
2041
+ var pin = markersById[id];
2042
+ if (pin && pin.el) {
2043
+ setActiveClass(pin.el, true);
2044
+ pin.el.style.zIndex = '2';
2045
+ }
2046
+ }
2047
+ if (opts.scrollCard && typeof row.li.scrollIntoView === 'function') {
2048
+ row.li.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
2049
+ }
2050
+ if (opts.panTo) panToIfHidden(id);
2051
+ }
2052
+ window.__selectPlace = selectPlace;
1514
2053
 
1515
- function buildPanelRow(it) {
2054
+ // Moves the map only when the pin, or the name label under it, is off
2055
+ // screen or under the carousel, so swiping through nearby results
2056
+ // doesn't keep shifting it.
2057
+ var PIN_LABEL_ROOM = 52;
2058
+ function panToIfHidden(id) {
2059
+ var entry = markersById[id];
2060
+ if (!entry || !map || typeof map.project !== 'function') return;
2061
+ var lngLat = entry.marker.getLngLat();
2062
+ var p = map.project(lngLat);
2063
+ var el = document.getElementById('map');
2064
+ var width = (el && el.clientWidth) || 0;
2065
+ var height = (el && el.clientHeight) || 0;
2066
+ if (!width || !height) return;
2067
+ var visibleBottom = height - carouselTop() - PIN_LABEL_ROOM;
2068
+ if (p.x >= 24 && p.x <= width - 24 && p.y >= 24 && p.y <= visibleBottom) return;
2069
+ // Just far enough to bring it 24px inside the visible area, so the
2070
+ // pins around it mostly stay in view.
2071
+ var x = Math.min(Math.max(p.x, 48), width - 48);
2072
+ var y = Math.min(Math.max(p.y, 48), Math.max(48, visibleBottom - 24));
2073
+ map.panBy([p.x - x, p.y - y], { duration: 500 });
2074
+ }
2075
+
2076
+ function buildCard(it) {
1516
2077
  var li = document.createElement('li');
1517
- li.className = 'panel-item';
2078
+ li.className = 'card';
1518
2079
 
1519
- var badge = document.createElement('span');
1520
- badge.className = 'panel-badge';
1521
- badge.textContent = String(it.number);
2080
+ var badge = null;
2081
+ if (it.number !== null && it.number !== undefined) {
2082
+ badge = document.createElement('span');
2083
+ badge.className = 'card-badge';
2084
+ badge.textContent = String(it.number);
2085
+ }
1522
2086
  // Match the pin when the caller colored it (e.g. gray for "other
1523
2087
  // results" next to a legend); the default orange is in the CSS.
1524
- if (typeof it.color === 'string' && it.color.toLowerCase() !== '#f97316' &&
2088
+ if (badge && typeof it.color === 'string' && it.color.toLowerCase() !== '#f97316' &&
1525
2089
  (/^#[0-9a-f]{3,8}$/i).test(it.color)) {
1526
2090
  badge.style.background = it.color;
1527
- badge.style.color = '#fff';
2091
+ badge.style.color = badgeTextColor(it.color);
1528
2092
  }
1529
2093
 
1530
2094
  var name = document.createElement('div');
1531
- name.className = 'panel-name';
2095
+ name.className = 'card-name';
1532
2096
  name.textContent = it.name;
1533
2097
 
1534
- var metaParts = [];
1535
- if (it.category) metaParts.push(it.category);
1536
- if (typeof it.distanceMeters === 'number') {
1537
- metaParts.push(Math.round(it.distanceMeters) + ' m');
1538
- }
1539
2098
  var meta = document.createElement('div');
1540
- meta.className = 'panel-meta';
1541
- meta.textContent = metaParts.join(' · ');
2099
+ meta.className = 'card-meta';
2100
+ meta.textContent = cardMetaText(it, null);
1542
2101
 
1543
2102
  var body = document.createElement('div');
2103
+ body.className = 'card-body';
1544
2104
  body.appendChild(name);
1545
2105
  body.appendChild(meta);
1546
- li.appendChild(badge);
2106
+ // Placeholder picture, replaced by the photo if one loads (see
2107
+ // loadPanelPhoto). Shown first by CSS order.
2108
+ var thumb = document.createElement('span');
2109
+ thumb.className = 'card-thumb empty';
2110
+ thumb.innerHTML = iconSvg(it.icon);
2111
+ if (typeof thumb.setAttribute === 'function') {
2112
+ thumb.setAttribute('role', 'img');
2113
+ thumb.setAttribute('aria-label', it.name);
2114
+ }
2115
+ if (badge) li.appendChild(badge);
1547
2116
  li.appendChild(body);
2117
+ li.appendChild(thumb);
1548
2118
 
1549
2119
  li.addEventListener('click', function() {
1550
- var entry = markersById[it.id];
1551
- if (entry) map.flyTo({ center: entry.marker.getLngLat(), zoom: 15, duration: 500 });
2120
+ selectPlace(it.id, { scrollCard: true, panTo: true });
1552
2121
  });
1553
2122
 
1554
- panelRowsById[it.id] = { li: li, meta: meta };
2123
+ panelRowsById[it.id] = { li: li, meta: meta, body: body, thumb: thumb, item: it };
1555
2124
  return li;
1556
2125
  }
1557
2126
 
1558
- function clearPanel() {
1559
- while (panelEl.firstChild) panelEl.removeChild(panelEl.firstChild);
1560
- panelRowsById = {};
1561
- panelListEl = null;
2127
+ // "Cafe · $$ · 120 m". Search Box categories come lowercase.
2128
+ function cardMetaText(it, price) {
2129
+ var parts = [];
2130
+ if (it.category) parts.push(it.category.charAt(0).toUpperCase() + it.category.slice(1));
2131
+ if (price) parts.push(price);
2132
+ if (typeof it.distanceMeters === 'number') {
2133
+ parts.push(Math.round(it.distanceMeters) + ' m');
2134
+ }
2135
+ return parts.join(' · ');
1562
2136
  }
1563
2137
 
1564
- function applyPanelCollapsed() {
1565
- panelEl.className = panelCollapsed ? 'collapsed' : '';
2138
+ // Places API attributes.price_level as dollar signs. Unknown levels show
2139
+ // nothing rather than a guess.
2140
+ var PRICE_SIGNS = { 'cheap': '$', 'moderate': '$$', 'expensive': '$$$', 'very expensive': '$$$$' };
2141
+ function priceSigns(level) {
2142
+ if (typeof level !== 'string') return null;
2143
+ return PRICE_SIGNS[level.trim().toLowerCase()] || null;
1566
2144
  }
2145
+ window.__priceSigns = priceSigns;
1567
2146
 
1568
- function togglePanel() {
1569
- panelCollapsed = !panelCollapsed;
1570
- applyPanelCollapsed();
1571
- if (lastFitBounds) fitToBounds(lastFitBounds);
2147
+ function clearPanel() {
2148
+ while (carouselEl.firstChild) carouselEl.removeChild(carouselEl.firstChild);
2149
+ panelRowsById = {};
2150
+ cardListEl = null;
2151
+ cardOrder = [];
2152
+ activePlaceId = null;
2153
+ }
2154
+
2155
+ // Swiping selects whichever card ends up in the middle.
2156
+ var scrollTimer = null;
2157
+ function onCardScroll() {
2158
+ if (scrollTimer) clearTimeout(scrollTimer);
2159
+ scrollTimer = setTimeout(function() {
2160
+ scrollTimer = null;
2161
+ if (!cardListEl) return;
2162
+ var center = (cardListEl.scrollLeft || 0) + (cardListEl.clientWidth || 0) / 2;
2163
+ var best = null;
2164
+ var bestDist = Infinity;
2165
+ cardOrder.forEach(function(id) {
2166
+ var row = panelRowsById[id];
2167
+ if (!row) return;
2168
+ var mid = (row.li.offsetLeft || 0) + (row.li.offsetWidth || 0) / 2;
2169
+ var d = Math.abs(mid - center);
2170
+ if (d < bestDist) { bestDist = d; best = id; }
2171
+ });
2172
+ if (best && best !== activePlaceId) selectPlace(best, { panTo: true });
2173
+ }, 120);
1572
2174
  }
1573
2175
 
1574
- // Creates the panel on the render's first id-bearing markers, and adds
1575
- // rows to it for later ones (e.g. search results merged in after inline
1576
- // markers), so every numbered pin has a row.
1577
- function appendPanelRows(items, sourceLabel) {
2176
+ // Creates the carousel on the render's first id-bearing markers, and adds
2177
+ // cards for later ones (e.g. search results merged in after inline
2178
+ // markers), so every numbered pin has a card. The first card starts
2179
+ // selected.
2180
+ function appendCards(items) {
1578
2181
  if (!Array.isArray(items) || items.length === 0) return;
1579
- if (!panelListEl) {
1580
- var head = document.createElement('div');
1581
- head.className = 'panel-head';
1582
- head.textContent = sourceLabel || 'Results';
1583
- head.addEventListener('click', togglePanel);
1584
-
1585
- panelListEl = document.createElement('ul');
1586
- panelListEl.className = 'panel-list';
1587
-
1588
- panelEl.appendChild(head);
1589
- panelEl.appendChild(panelListEl);
1590
- // On a narrow map the open panel would cover most of it.
1591
- if (panelCollapsed === null) {
1592
- var width = mapContainerWidth();
1593
- panelCollapsed = width > 0 && width < 560;
2182
+ var created = false;
2183
+ if (!cardListEl) {
2184
+ cardListEl = document.createElement('ul');
2185
+ cardListEl.className = 'card-list';
2186
+ cardListEl.addEventListener('scroll', onCardScroll);
2187
+ carouselEl.appendChild(cardListEl);
2188
+ carouselEl.style.display = 'block';
2189
+ created = true;
2190
+ }
2191
+ var list = cardListEl;
2192
+ items.forEach(function(it) {
2193
+ list.appendChild(buildCard(it));
2194
+ cardOrder.push(it.id);
2195
+ });
2196
+ // The legends (bottom-left) move up above the cards.
2197
+ legendsEl.style.bottom = (carouselTop() + 8) + 'px';
2198
+ if (created) selectPlace(items[0].id);
2199
+ }
2200
+
2201
+ // Opening hours from a Places API opening_hours string, which uses OSM
2202
+ // syntax: "Mo-Fr 07:00-19:00; Sa,Su 08:00-17:00", "Mo 09:00-12:00,13:00-17:00",
2203
+ // "Su off", "24/7". Returns null for anything else (holidays, months, week
2204
+ // numbers), so a card shows no hours rather than wrong ones.
2205
+ //
2206
+ // With the place's time zone (Places telemetry.tags.timezone) we know its
2207
+ // clock, so the card says whether it's open now: { state: 'open', text:
2208
+ // 'until 8:30 PM' } or { state: 'closed', text: '· opens 7:30 AM' }.
2209
+ // Without one we only know the viewer's day, so the card shows today's
2210
+ // hours: { state: 'hours', text: 'Open 7:30 AM–3:00 PM' }. Times use the
2211
+ // viewer's locale (7:30–15:00 in en-GB).
2212
+ var OSM_WEEK = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
2213
+ var INTL_WEEK = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
2214
+ var HOURS_RANGE = /^[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}$/;
2215
+ function osmDaysInclude(days, day) {
2216
+ var parts = days.split(',');
2217
+ var hit = false;
2218
+ for (var i = 0; i < parts.length; i++) {
2219
+ var ends = parts[i].split('-');
2220
+ var a = OSM_WEEK.indexOf(ends[0]);
2221
+ var b = ends.length === 2 ? OSM_WEEK.indexOf(ends[1]) : a;
2222
+ if (a === -1 || b === -1 || ends.length > 2) return null;
2223
+ if (a <= b ? (day >= a && day <= b) : (day >= a || day <= b)) hit = true;
2224
+ }
2225
+ return hit;
2226
+ }
2227
+ function toMinutes(hhmm) {
2228
+ return parseInt(hhmm.slice(0, 2), 10) * 60 + parseInt(hhmm.slice(3, 5), 10);
2229
+ }
2230
+ // A day's opening ranges as [startMinute, endMinute] pairs (end <= start
2231
+ // runs past midnight), 'always' for 24/7, or null when unparseable.
2232
+ function osmDayRanges(spec, day) {
2233
+ if (spec === '24/7') return 'always';
2234
+ var result = [];
2235
+ var rules = spec.split(';');
2236
+ for (var i = 0; i < rules.length; i++) {
2237
+ var rule = rules[i].trim();
2238
+ if (!rule) continue;
2239
+ var space = rule.indexOf(' ');
2240
+ if (space === -1) return null;
2241
+ var applies = osmDaysInclude(rule.slice(0, space), day);
2242
+ if (applies === null) return null;
2243
+ var times = rule.slice(space + 1).trim();
2244
+ var ranges = times === 'off' || times === 'closed' ? [] : times.split(',');
2245
+ var parsed = [];
2246
+ for (var j = 0; j < ranges.length; j++) {
2247
+ var r = ranges[j].trim();
2248
+ if (!HOURS_RANGE.test(r)) return null;
2249
+ parsed.push([toMinutes(r.slice(0, 5)), toMinutes(r.slice(6, 11))]);
1594
2250
  }
1595
- applyPanelCollapsed();
1596
- panelEl.style.display = 'flex';
2251
+ // In OSM a later rule for the same day overrides an earlier one.
2252
+ if (applies) result = parsed;
1597
2253
  }
1598
- var list = panelListEl;
1599
- items.forEach(function(it) { list.appendChild(buildPanelRow(it)); });
2254
+ return result;
1600
2255
  }
2256
+ // The place's weekday (0 = Monday) and minute of the day, or null for a
2257
+ // missing or unknown time zone.
2258
+ function clockIn(timeZone, now) {
2259
+ if (typeof timeZone !== 'string' || !timeZone) return null;
2260
+ var parts;
2261
+ try {
2262
+ parts = new Intl.DateTimeFormat('en-US', {
2263
+ timeZone: timeZone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23'
2264
+ }).formatToParts(now);
2265
+ } catch (e) {
2266
+ return null;
2267
+ }
2268
+ var p = {};
2269
+ parts.forEach(function(part) { p[part.type] = part.value; });
2270
+ var day = INTL_WEEK.indexOf(p.weekday);
2271
+ if (day === -1) return null;
2272
+ return { day: day, minutes: (parseInt(p.hour, 10) % 24) * 60 + parseInt(p.minute, 10) };
2273
+ }
2274
+ function formatMinutes(minutes, locale) {
2275
+ var d = new Date(2000, 0, 1, Math.floor(minutes / 60) % 24, minutes % 60);
2276
+ try {
2277
+ return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(d);
2278
+ } catch (e) {
2279
+ return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(d);
2280
+ }
2281
+ }
2282
+ function hoursToday(spec, now, timeZone, locale) {
2283
+ if (typeof spec !== 'string') return null;
2284
+ var text = spec.trim();
2285
+ if (!text) return null;
2286
+ var clock = clockIn(timeZone, now);
2287
+ var day = clock ? clock.day : (now.getDay() + 6) % 7;
2288
+ var today = osmDayRanges(text, day);
2289
+ if (today === null) return null;
2290
+ if (today === 'always') return { state: 'open', text: '24 hours' };
2291
+ var fmt = function(m) { return formatMinutes(m, locale); };
2292
+ if (!clock) {
2293
+ if (today.length === 0) return { state: 'closed', text: 'today' };
2294
+ return {
2295
+ state: 'hours',
2296
+ text: 'Open ' + today.map(function(r) { return fmt(r[0]) + '–' + fmt(r[1]); }).join(', ')
2297
+ };
2298
+ }
2299
+ var t = clock.minutes;
2300
+ // Still open from a range that started yesterday and runs past midnight.
2301
+ var yesterday = osmDayRanges(text, (day + 6) % 7) || [];
2302
+ for (var i = 0; i < yesterday.length; i++) {
2303
+ if (yesterday[i][1] <= yesterday[i][0] && t < yesterday[i][1]) {
2304
+ return { state: 'open', text: 'until ' + fmt(yesterday[i][1]) };
2305
+ }
2306
+ }
2307
+ var next = null;
2308
+ for (var j = 0; j < today.length; j++) {
2309
+ var s = today[j][0];
2310
+ var e = today[j][1];
2311
+ var overnight = e <= s;
2312
+ if (t >= s && (overnight || t < e)) return { state: 'open', text: 'until ' + fmt(e) };
2313
+ if (t < s && (next === null || s < next)) next = s;
2314
+ }
2315
+ return { state: 'closed', text: next === null ? '' : '· opens ' + fmt(next) };
2316
+ }
2317
+ window.__hoursToday = hoursToday;
1601
2318
 
1602
2319
  // Search Box results mix Mapbox-native POI ids (decode to
1603
2320
  // "urn:mbxpoi:<uuid>") with OSM-sourced ones (decode to
@@ -1623,7 +2340,7 @@ ${initialDataScript}
1623
2340
 
1624
2341
  // Places API Details endpoint — batch form. Mirrors the single-record
1625
2342
  // GET src/tools/place-details-tool/PlaceDetailsTool.ts calls server-side,
1626
- // but the panel needs details for every result at once, so this uses the
2343
+ // but the carousel needs details for every result at once, so this uses the
1627
2344
  // POST /details/retrieve batch form (up to 100 ids) instead of one
1628
2345
  // request per pin. Docs: https://docs.mapbox.com/api/search/places/
1629
2346
  // Like every other self-fetch call, this runs client-side against the
@@ -1658,18 +2375,18 @@ ${initialDataScript}
1658
2375
  });
1659
2376
  };
1660
2377
  return post(compatibleIds.slice(0, 100), false)
1661
- // Enrichment is a nice-to-have on top of an already-rendered panel —
2378
+ // Enrichment is a nice-to-have on top of already-rendered cards —
1662
2379
  // Public Preview quota (1,000 records/month) or a transient error
1663
2380
  // here should never surface as a map error, just leave the
1664
- // Search-Box-only rows as they are.
2381
+ // Search-Box-only cards as they are.
1665
2382
  .catch(function() { return null; });
1666
2383
  }
1667
2384
 
1668
2385
  // Place Details photos are hosted wherever each business's site hosts
1669
2386
  // them, so the in-chat CSP (mapAppCsp.ts) can't allow them. Inside a host,
1670
2387
  // the page asks the server for the photo (PlacePhotoResource, by mapbox_id)
1671
- // and shows it as a data: URL. A standalone page from export_map_html_tool
1672
- // has no host and no CSP, so it loads the photo URL directly.
2388
+ // and shows it as a data: URL. A page with no host has no CSP of ours, so
2389
+ // it loads the photo URL directly.
1673
2390
  var MAX_PANEL_PHOTOS = 10;
1674
2391
  var EMBEDDED = window.parent !== window;
1675
2392
  var PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
@@ -1677,17 +2394,16 @@ ${initialDataScript}
1677
2394
 
1678
2395
  function loadPanelPhoto(row, id, photoUrl) {
1679
2396
  var show = function(src) {
1680
- // A later render may have replaced the panel while this loaded.
2397
+ // A later render may have replaced the carousel while this loaded.
1681
2398
  if (panelRowsById[id] !== row) return;
1682
- var thumb = document.createElement('span');
1683
- thumb.className = 'panel-thumb';
1684
- thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")';
1685
- row.li.appendChild(thumb);
2399
+ row.thumb.className = 'card-thumb';
2400
+ row.thumb.innerHTML = '';
2401
+ row.thumb.style.backgroundImage = 'url("' + src.replace(/"/g, '') + '")';
1686
2402
  };
1687
2403
  if (!EMBEDDED || !hostAnswered) {
1688
- // No MCP host (a standalone or framed exported page), so no CSP of
1689
- // ours either. Load it first: some sites refuse hotlinked photos (403), which
1690
- // would leave an empty box in the row.
2404
+ // No MCP host (a standalone or framed page), so no CSP of ours
2405
+ // either. Load it first: some sites refuse hotlinked photos (403),
2406
+ // which would leave an empty box on the card.
1691
2407
  if (photoUrl.indexOf('https://') !== 0 || typeof Image !== 'function') return;
1692
2408
  var img = new Image();
1693
2409
  img.onload = function() { show(photoUrl); };
@@ -1712,27 +2428,47 @@ ${initialDataScript}
1712
2428
  detailsResult.results.forEach(function(place) {
1713
2429
  if (!place || !place.mapbox_id) return;
1714
2430
  var row = panelRowsById[place.mapbox_id];
1715
- if (row) {
1716
- var photoUrl = place.photos && place.photos[0] && place.photos[0].url;
1717
- if (typeof photoUrl === 'string' && photosRequested < MAX_PANEL_PHOTOS) {
1718
- photosRequested++;
1719
- loadPanelPhoto(row, place.mapbox_id, photoUrl);
1720
- }
1721
- if (place.score && typeof place.score.popularity === 'number') {
1722
- var extra = Math.round(place.score.popularity * 100) + '% popularity';
1723
- row.meta.textContent = row.meta.textContent ? row.meta.textContent + ' · ' + extra : extra;
1724
- }
2431
+ if (!row) return;
2432
+ var photoUrl = place.photos && place.photos[0] && place.photos[0].url;
2433
+ if (typeof photoUrl === 'string' && photosRequested < MAX_PANEL_PHOTOS) {
2434
+ photosRequested++;
2435
+ loadPanelPhoto(row, place.mapbox_id, photoUrl);
1725
2436
  }
1726
-
1727
- var entry = markersById[place.mapbox_id];
1728
- if (entry && place.phone) {
1729
- entry.marker.setPopup(
1730
- new mapboxgl.Popup().setText(entry.popupText + ' — ' + place.phone)
1731
- );
2437
+ var price = priceSigns(place.attributes && place.attributes.price_level);
2438
+ if (price) row.meta.textContent = cardMetaText(row.item, price);
2439
+ // Hours on a second line: a green "Open" or red "Closed" when we know
2440
+ // the place's clock. Places has no star rating, and score.popularity
2441
+ // isn't one, so the card shows neither.
2442
+ var timeZone = place.telemetry && place.telemetry.tags && place.telemetry.tags.timezone;
2443
+ var hours = hoursToday(place.opening_hours, new Date(), timeZone);
2444
+ if (hours) {
2445
+ var line = document.createElement('div');
2446
+ line.className = 'card-extra';
2447
+ if (hours.state === 'hours') {
2448
+ line.textContent = hours.text;
2449
+ } else {
2450
+ var status = document.createElement('span');
2451
+ status.className = hours.state === 'open' ? 'hours-open' : 'hours-closed';
2452
+ status.textContent = hours.state === 'open' ? 'Open' : 'Closed';
2453
+ line.appendChild(status);
2454
+ if (hours.text) {
2455
+ var rest = document.createElement('span');
2456
+ rest.textContent = ' ' + hours.text;
2457
+ line.appendChild(rest);
2458
+ }
2459
+ }
2460
+ row.body.appendChild(line);
1732
2461
  }
1733
2462
  });
1734
2463
  }
1735
2464
 
2465
+ // A proximity the server filled in from the first result (see
2466
+ // searchSelfFetchProximity) only steers the re-fetch: it isn't a center
2467
+ // the user asked about, so no "Search center" pin and no distances.
2468
+ function searchCenter(params) {
2469
+ return params.proximityFromResults ? undefined : params.proximity;
2470
+ }
2471
+
1736
2472
  function selfFetchSearch(params) {
1737
2473
  if (!TOKEN || !isSafeSearchParams(params)) {
1738
2474
  showError('Could not fetch search results: missing token or invalid parameters.');
@@ -1746,10 +2482,7 @@ ${initialDataScript}
1746
2482
  .json()
1747
2483
  .catch(function() { return null; })
1748
2484
  .then(function(body) {
1749
- var msg =
1750
- body && body.message
1751
- ? body.message
1752
- : 'Search API error (' + res.status + ')';
2485
+ var msg = apiErrorText(body, 'Search API error (' + res.status + ')');
1753
2486
  throw new Error(msg);
1754
2487
  });
1755
2488
  }
@@ -1767,7 +2500,7 @@ ${initialDataScript}
1767
2500
  });
1768
2501
  if (match.length > 0) fc = { type: 'FeatureCollection', features: match };
1769
2502
  }
1770
- var mini = buildSearchMiniPayload(fc, params.q, params.proximity);
2503
+ var mini = buildSearchMiniPayload(fc, params.q, searchCenter(params));
1771
2504
  if (!mini) {
1772
2505
  showError('Search API returned no results.');
1773
2506
  return;
@@ -1835,17 +2568,14 @@ ${initialDataScript}
1835
2568
  .json()
1836
2569
  .catch(function() { return null; })
1837
2570
  .then(function(body) {
1838
- var msg =
1839
- body && body.message
1840
- ? body.message
1841
- : 'Category Search API error (' + res.status + ')';
2571
+ var msg = apiErrorText(body, 'Category Search API error (' + res.status + ')');
1842
2572
  throw new Error(msg);
1843
2573
  });
1844
2574
  }
1845
2575
  return res.json();
1846
2576
  })
1847
2577
  .then(function(data) {
1848
- var mini = buildSearchMiniPayload(data, params.category, params.proximity);
2578
+ var mini = buildSearchMiniPayload(data, params.category, searchCenter(params));
1849
2579
  if (!mini) {
1850
2580
  showError('Category Search API returned no results.');
1851
2581
  return;
@@ -1983,10 +2713,7 @@ ${initialDataScript}
1983
2713
  .json()
1984
2714
  .catch(function() { return null; })
1985
2715
  .then(function(body) {
1986
- var msg =
1987
- body && body.message
1988
- ? body.message
1989
- : 'Optimization API error (' + res.status + ')';
2716
+ var msg = apiErrorText(body, 'Optimization API error (' + res.status + ')');
1990
2717
  throw new Error(msg);
1991
2718
  });
1992
2719
  }
@@ -2044,7 +2771,7 @@ ${initialDataScript}
2044
2771
 
2045
2772
  // Mirrors the deleted server-side buildGroundLocationPayload (now
2046
2773
  // client-only since the map preview self-fetches): a dark origin pin plus
2047
- // numbered orange POI markers. Isochrone contours aren't drawn here either
2774
+ // place pins for the POIs. Isochrone contours aren't drawn here either
2048
2775
  // — the original never rendered them, only a contour-minutes summary.
2049
2776
  function buildGroundLocationMiniPayload(place, longitude, latitude, pois) {
2050
2777
  var markers = [
@@ -2056,7 +2783,7 @@ ${initialDataScript}
2056
2783
  }
2057
2784
  ];
2058
2785
  (pois || []).forEach(function(poi, i) {
2059
- var parts = [(i + 1) + '. ' + poi.name];
2786
+ var parts = [poi.name];
2060
2787
  if (poi.address) parts.push(poi.address);
2061
2788
  if (typeof poi.distance_meters === 'number') {
2062
2789
  parts.push(Math.round(poi.distance_meters) + ' m');
@@ -2064,9 +2791,7 @@ ${initialDataScript}
2064
2791
  markers.push({
2065
2792
  id: poi.id,
2066
2793
  coordinates: [poi.longitude, poi.latitude],
2067
- style: 'numbered',
2068
- label: String(i + 1),
2069
- color: '#f97316',
2794
+ style: 'place',
2070
2795
  popup: parts.join(' — '),
2071
2796
  name: poi.name,
2072
2797
  category: poi.category,