Skip to content

Commit 0325970

Browse files
ajrothwellclaude
andcommitted
fix: hide loading spinner, legend, and opacity when layer is zoom-restricted (close bead y4d)
When zoomed out past a layer's range, the loading indicator, legend, and opacity slider now hide instead of showing stale/misleading UI. Also bumps "(zoom in)" text from 11px to 13px to match loading text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9fcc8a4 commit 0325970

4 files changed

Lines changed: 14 additions & 13 deletions

File tree

.beads/issues.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@
4444
{"id":"vue3-layerboard-vee","title":"PlowPHL layers get fuzzy when zooming in - missing scale-based layer switching","description":"## Problem\nIn the streetsmartphl example, PlowPHL layers (Treated Street Status, etc.) look correct when zoomed out, but when zooming in the layer becomes fuzzy/pixelated. The graphics don't re-render at higher resolution - they just scale up the low-zoom tiles.\n\n## Root Cause\nThe vue3-layerboard implementation only uses tiled MapServer endpoints at all zoom levels. The legacy StreetSmartPHL app has a two-layer approach that switches between:\n- **zoomOut** (`tiled: true`): MapServer `/tile/` endpoint - fast, pre-cached tiles\n- **zoomIn** (`tiled: false`): Dynamic WebMap layers fetched from ArcGIS with full resolution\n\nThe legacy app gets `minScale`/`maxScale` from the ArcGIS REST API for each layer, watches the current map scale, and automatically swaps between the tiled and dynamic versions based on zoom level.\n\n## Affected Layers\nAll PlowPHL tiled layers in `examples/streetsmartphl/src/App.vue` (lines 193-224):\n- plowTreatedStreetsStatus\n- plowNotTreatedStreets \n- plowConditional\n- plowHighways\n\nURLs: `https://streetsgis.phila.gov/arcgis/rest/services/PlowPHL/{service}/MapServer`\n\n## Fix Required\nImplement the same two-layer approach with scale-based switching that the legacy app has:\n\n1. For each PlowPHL layer, define two versions:\n - zoomOut: current tiled MapServer approach\n - zoomIn: dynamic WebMap layer (need to find/add these to a WebMap or use /export/ endpoint)\n\n2. Fetch layer definitions with minScale/maxScale from ArcGIS REST API\n\n3. Watch map scale changes and toggle between the two layer versions based on the scale thresholds\n\n## Reference\nLegacy implementation in StreetSmartPHL:\n- `src/topics/plow.js` - topic-tiled-layers config with zoomIn/zoomOut pairs\n- `src/components/TopicTiledLayers.vue` - handles the switching logic\n- `src/components/Checkbox.vue` - shouldBeDisabled() checks minScale/maxScale\n\n## Testing Notes\nRequires an active snow event to test. May need to mock the storms API response to simulate a deployment.","notes":"**Mock in place for testing:** Line 530 in `examples/streetsmartphl/src/App.vue` has been changed to default to 'Full Deployment' when no storm data exists. This allows testing PlowPHL layers without an active snow event. Revert this line before committing any fix:\n```typescript\n// Current (mocked):\nreturn stormsData?.Deployment || 'Full Deployment' // MOCK: remove before committing\n\n// Original:\nreturn stormsData?.Deployment || ''\n```\n## Implementation Complete (2026-01-29)\n\nScale-based layer switching implemented on branch `wip/plowphl-zoom-fix`:\n- Layers with `scaleBasedRendering: true` fetch MapServer metadata for scale thresholds\n- Zoomed out: uses /tile/ endpoint (fast, cached)\n- Zoomed in: uses /export/ endpoint (dynamic, sharp)\n\n**Testing limitation:** Can't fully verify without active snow event. Export requests return 200 OK but images are empty (no current data on server). Mock removed - awaiting next snow event to verify.","status":"open","priority":2,"issue_type":"bug","owner":"rothwell.andy@gmail.com","created_at":"2026-01-29T12:57:20.0734585-05:00","created_by":"rothwell.andy@gmail.com","updated_at":"2026-01-29T14:02:39.6851907-05:00"}
4545
{"id":"vue3-layerboard-vtj","title":"Offset popup placement so it doesn't obscure clicked feature","description":"Currently the popup is placed exactly at the click coordinates, which means it sits directly on top of the clicked feature and obscures it. The old (Vue 2) version offset the popup upward by some pixels so the popup's pointer/tail was near the click point but the popup body was above it. That made it much easier to see the feature you selected (e.g. a planning district polygon). Should add a vertical offset so the popup doesn't cover the thing you just clicked.","status":"closed","priority":3,"issue_type":"task","owner":"rothwell.andy@gmail.com","created_at":"2026-02-12T09:55:22.2328703-05:00","created_by":"rothwell.andy@gmail.com","updated_at":"2026-02-12T10:33:57.2782418-05:00","closed_at":"2026-02-12T10:33:57.2782418-05:00","close_reason":"Closed"}
4646
{"id":"vue3-layerboard-wax","title":"Census Block Groups - all blocks show as group 1, 2, or 3","description":"Census Block Groups - 2020 layer appears to only show block groups numbered 1, 2, or 3. All census block groups across the city display one of these three values, which seems wrong — there should be a much wider range of block group numbers. Investigate whether this is a data issue from the ArcGIS service, a popup field mapping problem, or a renderer/symbology issue in layerboard.","status":"closed","priority":3,"issue_type":"task","owner":"rothwell.andy@gmail.com","created_at":"2026-02-12T13:54:26.593089-05:00","created_by":"rothwell.andy@gmail.com","updated_at":"2026-02-13T10:13:23.0291768-05:00","closed_at":"2026-02-13T10:13:23.0291768-05:00","close_reason":"Closed"}
47+
{"id":"vue3-layerboard-y4d","title":"Zoom-restricted layers show loading spinner and keep legend/opacity when deactivated","description":"## Problem\n\nWhen a layer is zoom-restricted (only visible at certain zoom levels) and the user is zoomed out beyond its visible range:\n\n1. **Loading spinner/message still shows** — The \"Loading...\" text and spinner appear even though the layer will never load at the current zoom. This makes it look like the layer is trying to load when it's actually deactivated due to zoom level.\n\n2. **Legend stays visible** — The legend colors remain visible in the sidebar even though those colors won't appear on the map. This is confusing because it suggests the layer might be rendering.\n\n3. **Opacity slider stays active** — The opacity slider remains functional even though adjusting it has no effect at the current zoom level.\n\n4. **\"(zoom in)\" text is too small** — The \"(zoom in)\" indicator should be as large as the \"Loading...\" text for better visibility.\n\n## Expected Behavior\n\nWhen a layer is deactivated due to zoom level:\n- Loading spinner and \"Loading...\" message should NOT appear\n- Legend should disappear (or be hidden)\n- Opacity slider should disappear or be grayed out/disabled\n- \"(zoom in)\" text should be prominently sized (matching \"Loading...\" text size)\n","status":"closed","priority":2,"issue_type":"bug","owner":"rothwell.andy@gmail.com","created_at":"2026-02-17T09:09:48.5905794-05:00","created_by":"rothwell.andy@gmail.com","updated_at":"2026-02-17T09:21:16.55208-05:00","closed_at":"2026-02-17T09:21:16.55208-05:00","close_reason":"Closed"}

src/components/LayerCheckboxSet.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function onOpacityChange(layerId: string, event: Event) {
127127
/>
128128
<span class="layer-title">
129129
{{ getLayerDisplayName(layer) }}
130-
<span v-if="isLayerLoading(layer.id)" class="loading-indicator" role="status"> Loading... </span>
130+
<span v-if="isLayerLoading(layer.id) && isLayerAvailableAtZoom(layer)" class="loading-indicator" role="status"> Loading... </span>
131131
<span
132132
v-if="getLayerError(layer.id)"
133133
class="error-indicator"
@@ -151,7 +151,7 @@ function onOpacityChange(layerId: string, event: Event) {
151151
>
152152
<span class="layer-title">
153153
{{ getLayerDisplayName(layer) }}
154-
<span v-if="isLayerLoading(layer.id)" class="loading-indicator" role="status"> Loading... </span>
154+
<span v-if="isLayerLoading(layer.id) && isLayerAvailableAtZoom(layer)" class="loading-indicator" role="status"> Loading... </span>
155155
<span
156156
v-if="getLayerError(layer.id)"
157157
class="error-indicator"
@@ -165,7 +165,7 @@ function onOpacityChange(layerId: string, event: Event) {
165165
</div>
166166

167167
<!-- Opacity slider (respects per-layer shouldShowSlider) -->
168-
<div v-if="shouldShowSlider(layer) && isVisible(layer.id)" class="opacity-control">
168+
<div v-if="shouldShowSlider(layer) && isVisible(layer.id) && isLayerAvailableAtZoom(layer)" class="opacity-control">
169169
<label class="opacity-label" :for="'opacity-' + layer.id">
170170
Opacity: {{ Math.round(getLayerOpacity(layer.id) * 100) }}%
171171
</label>
@@ -184,7 +184,7 @@ function onOpacityChange(layerId: string, event: Event) {
184184

185185
<!-- Legend (respects per-layer shouldShowLegendBox) -->
186186
<ul
187-
v-if="shouldShowLegendBox(layer) && isVisible(layer.id) && layer.legend?.length"
187+
v-if="shouldShowLegendBox(layer) && isVisible(layer.id) && isLayerAvailableAtZoom(layer) && layer.legend?.length"
188188
class="layer-legend"
189189
:aria-label="'Legend for ' + getLayerDisplayName(layer)"
190190
>
@@ -300,7 +300,7 @@ function onOpacityChange(layerId: string, event: Event) {
300300
}
301301
302302
.zoom-indicator {
303-
font-size: 11px;
303+
font-size: 13px;
304304
color: #767676;
305305
font-style: italic;
306306
margin-left: 4px;

src/components/LayerPanel.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ function onOpacityChange(layerId: string, event: Event) {
180180
/>
181181
<span class="layer-title">
182182
{{ layer.config.title }}
183-
<span v-if="isLayerLoading(layer.config.id)" class="loading-indicator" role="status"> Loading... </span>
183+
<span v-if="isLayerLoading(layer.config.id) && isLayerAvailableAtZoom(layer.config)" class="loading-indicator" role="status"> Loading... </span>
184184
<span
185185
v-if="getLayerError(layer.config.id)"
186186
class="error-indicator"
@@ -195,7 +195,7 @@ function onOpacityChange(layerId: string, event: Event) {
195195
</div>
196196

197197
<!-- Opacity slider (shown when layer is visible and showOpacity is true) -->
198-
<div v-if="showOpacity && isVisible(layer.config.id)" class="opacity-control">
198+
<div v-if="showOpacity && isVisible(layer.config.id) && isLayerAvailableAtZoom(layer.config)" class="opacity-control">
199199
<label class="opacity-label" :for="'opacity-' + layer.config.id">
200200
Opacity: {{ Math.round(getLayerOpacity(layer.config.id) * 100) }}%
201201
</label>
@@ -214,7 +214,7 @@ function onOpacityChange(layerId: string, event: Event) {
214214

215215
<!-- Legend (shown when layer is visible, showLegend is true, and has legend items) -->
216216
<ul
217-
v-if="showLegend && isVisible(layer.config.id) && layer.config.legend?.length"
217+
v-if="showLegend && isVisible(layer.config.id) && isLayerAvailableAtZoom(layer.config) && layer.config.legend?.length"
218218
class="layer-legend"
219219
:aria-label="'Legend for ' + layer.config.title"
220220
>
@@ -404,7 +404,7 @@ function onOpacityChange(layerId: string, event: Event) {
404404
}
405405
406406
.zoom-indicator {
407-
font-size: 11px;
407+
font-size: 13px;
408408
color: #767676;
409409
font-style: italic;
410410
margin-left: 4px;

src/components/LayerRadioButtonSet.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ function onOpacityChange(layerId: string, event: Event) {
126126
/>
127127
<span class="layer-title">
128128
{{ getLayerDisplayName(layer) }}
129-
<span v-if="isLayerLoading(layer.id)" class="loading-indicator" role="status"> Loading... </span>
129+
<span v-if="isLayerLoading(layer.id) && isLayerAvailableAtZoom(layer)" class="loading-indicator" role="status"> Loading... </span>
130130
<span
131131
v-if="getLayerError(layer.id)"
132132
class="error-indicator"
@@ -140,7 +140,7 @@ function onOpacityChange(layerId: string, event: Event) {
140140
</label>
141141

142142
<!-- Opacity slider (respects per-layer shouldShowSlider) -->
143-
<div v-if="shouldShowSlider(layer) && isVisible(layer.id)" class="opacity-control">
143+
<div v-if="shouldShowSlider(layer) && isVisible(layer.id) && isLayerAvailableAtZoom(layer)" class="opacity-control">
144144
<label class="opacity-label" :for="'opacity-' + layer.id">
145145
Opacity: {{ Math.round(getLayerOpacity(layer.id) * 100) }}%
146146
</label>
@@ -159,7 +159,7 @@ function onOpacityChange(layerId: string, event: Event) {
159159

160160
<!-- Legend (respects per-layer shouldShowLegendBox) -->
161161
<ul
162-
v-if="shouldShowLegendBox(layer) && isVisible(layer.id) && layer.legend?.length"
162+
v-if="shouldShowLegendBox(layer) && isVisible(layer.id) && isLayerAvailableAtZoom(layer) && layer.legend?.length"
163163
class="layer-legend"
164164
:aria-label="'Legend for ' + getLayerDisplayName(layer)"
165165
>
@@ -263,7 +263,7 @@ function onOpacityChange(layerId: string, event: Event) {
263263
}
264264
265265
.zoom-indicator {
266-
font-size: 11px;
266+
font-size: 13px;
267267
color: #767676;
268268
font-style: italic;
269269
margin-left: 4px;

0 commit comments

Comments
 (0)