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