Integrate to New Backend#209
Conversation
Replace direct TIMESERIES endpoint calls with a submit/poll/refine workflow for long-running time series extraction and analysis. Added new API constants (TIMESERIES_SUBMIT_ENDPOINT, TIMESERIES_STATUS_ENDPOINT, TIMESERIES_REFINE_ENDPOINT) and implemented submitTimeSeriesRequest, pollTimeSeriesStatus (with timeout and error mapping), and refineTimeSeriesAnalysis in the legacy store actions composable. Updated visualize and analyze pages to use the new flow (submit -> poll -> set jobId -> refine) and added improved error handling and user messages for timeouts, server errors, and missing baseline data. Also added jobId state and setter to the analysis store to persist extraction IDs.
…e logic Refactor time-series request handling to track per-variable job IDs in the dataset store and centralize resolve logic. - Add dataset.jobIds state with setJobId and clearJobIds; clear job IDs and time series when saving GeoJSON. - Introduce resolveTimeSeries in useLegacyStoreActions to attempt refine (reuse) and fall back to submit+poll for new jobs. - Update analyze and visualize pages to use datasetStore.jobIds[varId] and datasetStore.setJobId, remove duplicated requestJson helpers, and improve time-series loading/error handling and messages. - Remove jobId from analysis store (moved to dataset store) and delete related setters. These changes enable per-variable job tracking, better reuse of existing jobs, and consolidated error/state management for time-series requests.
Introduce raster COG tile support and tile endpoint handling for MapLibre maps. Changes include: - Expose TILES_ENDPOINT constant from store (API_HOST_URL/tiles). - Rename Map component prop `year` -> `step` and use it to select time slice tiles. - Build COG tile URLs from dataset/variable and step (including rescale and colormap params) and warn/fallback if variable min/max are missing. - Add functions to add, remove and update a raster layer (COG) using maplibre raster sources/tiles and respect dataset extents as bounds. - Add watchers to recreate/update the raster layer when `step` or the variable (cog base URL) changes. - Ensure metadata extent fill layer uses a centralized FILL_LAYER_ID and add the COG layer beneath it. - Enable raster display in the dataset visualize page and initialize the selected year from the dataset temporalRangeMin. Also includes small TODOs in code for colormap/time-resolution flexibility.
Introduce double-buffered raster loading for COG tiles (front/back slots) so step changes are atomic: the new step's tiles load invisibly and swap in all at once on idle, rather than updating tile-by-tile. Add slot management, idle-triggered swap, cancellation, and bounds handling. Expose and emit a new stepReady event (Map and MapLibrePoc) and pass step prop through Map. Wire map stepReady to TimeSeriesPlot.advanceAnimation via refs so the map controls animation progression. Include cleanup of pending swaps on unmount and minor related refactors. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Setting the stage for a possible implementation of flexibility when handling dataset time resolution.
Integrate a SkopeColorbar map control and step-loading UI into the MapLibre component. Key changes: - Added a new SkopeColorbar utility (app/utils/SkopeColorbar.ts) implementing a MapLibre control that renders a vertical colormap gradient with ticks and optional units, and supports update/show/hide. - Hooked the colorbar into MapLibre: create/add control on mount, update on min/max/unit changes, and remove on unmount. Added CSS styles for the colorbar. - Expose legend visibility and variable-derived values (min/max/units/colormap) via computed properties and use dataset variable colormap and stops. - Introduced a step-loading overlay (progress spinner) while raster tiles swap, and ensured a minimum display duration (STEP_DISPLAY_DURATION_MS = 1000ms) before emitting stepReady. Added timeout/cancel logic to manage step display and map idle swaps. - Apply color scaling cap (COLOR_MAX_PCT = 0.85) to rescale requests and colorbar vmax to avoid extreme outliers. - Updated getCogFullUrl to use datasetStore.variable.colormap and the capped max when building COG tile URLs. - Extended DatasetVariable type in app/stores/dataset.ts to include optional colormap and colormap_stops fields. These changes add a persistent, interactive legend and improve UX by showing a loading indicator and enforcing a brief display time for each step. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Expose a vmaxPct option on the SkopeColorbar and pass it from the MapLibre PoC so the colorbar can annotate the top tick with a “% of max” note. Tighten dataset typings by expanding DatasetVariable with optional metadata fields and remove some uses of any (variable is no longer cast to any). Also simplify computed accessors in MapLibrePoc for units/min/max and add SVG overflow attribute to the colorbar rendering. These changes improve type safety and allow the UI to indicate the percentage-based vmax annotation.
UX: enlarge MapLibre loading spinner and allow overflow for map container. TimeSeriesPlot: add a full-overlay loading state with progressive messages and timers (start/clear on status change and unmount), hide non-error alerts while loading, and add related CSS. Pages (analyze/visualize): surface user-friendly error notifications via messageStore.error on 504, 5xx and 4xx responses while still updating datasetStore error state. These changes improve feedback for long-running time-series requests and provide clearer error messages to users.
There was a problem hiding this comment.
Pull request overview
This PR integrates the UI with a new backend workflow for time series requests (submit → poll → refine), adds per-variable job tracking in the dataset store, and introduces COG tile rendering in MapLibre with a new Skope colorbar and step-based animation/transition behavior.
Changes:
- Implement centralized async time-series resolution (submit/poll/refine) with per-variable
jobIdstracking. - Add MapLibre COG raster tile layer support with double-buffered swapping and
stepReadyevents. - Add a MapLibre control colorbar with loading-state UX updates in the time series plot.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| app/utils/SkopeColorbar.ts | New MapLibre control to render a colorbar/legend for raster tiles. |
| app/stores/dataset.ts | Extends dataset variable metadata typing and adds per-variable jobIds. |
| app/store/modules/constants.js | Adds new backend endpoints for tiles and the submit/status/refine workflow. |
| app/pages/index.vue | Makes landing metadata load client-only (server: false). |
| app/pages/dataset/[id]/visualize/[variable].vue | Switches to step-based map/plot coordination and uses centralized time-series resolution. |
| app/pages/dataset/[id]/index.vue | Makes dataset initialization client-only (server: false). |
| app/pages/dataset/[id]/analyze/[variable].vue | Uses centralized time-series resolution and updates error/loading behavior. |
| app/composables/useLegacyStoreActions.ts | Adds submit/poll/refine/resolve logic and clears job IDs on new geometry. |
| app/components/dataset/TimeSeriesPlot.vue | Renames year→step UI, adds loading overlay and progressive loading messages, and exposes advanceAnimation. |
| app/components/dataset/MapLibrePoc.client.vue | Adds COG raster layer handling, step-ready eventing, loading overlay, and the colorbar control. |
| app/components/dataset/Map.client.vue | Renames prop to step and forwards stepReady. |
| .gitignore | Ignores .DS_Store. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } finally { | ||
| analysisStore.setWaitingForResponse(false); | ||
| datasetStore.setTimeSeriesLoaded(); | ||
| } |
There was a problem hiding this comment.
datasetStore.setTimeSeriesLoaded() is called in finally, so even when the request fails and you set timeout/server/badrequest statuses in catch, the status is immediately overwritten to success. Move setTimeSeriesLoaded() to the success path only (or guard it based on whether an error occurred).
| datasetStore.setTimeSeriesLoading(); | ||
| try { | ||
| const response = await requestJson(TIMESERIES_ENDPOINT, { | ||
| method: "POST", | ||
| body: JSON.stringify(data), | ||
| }); | ||
| const varId = route.params.variable as string; | ||
| const jobId = datasetStore.jobIds?.[varId]; | ||
| const {newJobId, response} = await legacyActions.resolveTimeSeries(jobId, data); | ||
| datasetStore.setJobId(varId, newJobId); | ||
| analysisStore.setResponse(response); |
There was a problem hiding this comment.
retrieveAnalysis() no longer toggles analysisStore.waitingForResponse, but the page still checks analysisStore.waitingForResponse (e.g. to prevent duplicate submits). Either restore analysisStore.setWaitingForResponse(true/false) around the async request, or remove/update the guard to use the dataset loading status instead.
| datasetStore.setTimeSeriesBadRequest(detail); | ||
| messageStore.error(detail.map((d: any) => d.msg).join(" ") || "Bad request."); | ||
| } | ||
| } else { |
There was a problem hiding this comment.
In the non-HTTP-error branch (else when e.response is falsy), the store status is never updated after setTimeSeriesLoading(), so the UI can remain stuck in the loading state on network/client errors. Set an appropriate error/timeout status in the store (or ensure loading is cleared) in this branch as well.
| } else { | |
| } else { | |
| datasetStore.setTimeSeriesTimeout(); |
| console.warn(`Variable ${datasetStore.variable?.id} is missing min/max — falling back to rescale 0,100`) | ||
| } | ||
| const min = datasetStore.variable?.min ?? 0; | ||
| const max = datasetStore.variable?.max * COLOR_MAX_PCT ?? 100; |
There was a problem hiding this comment.
const max = datasetStore.variable?.max * COLOR_MAX_PCT ?? 100; can produce NaN when variable.max is undefined (because undefined * pct is NaN, and ?? does not catch NaN). This will leak into the rescale query param and break tile rendering. Compute the fallback before multiplying (or validate with Number.isFinite).
| const max = datasetStore.variable?.max * COLOR_MAX_PCT ?? 100; | |
| const max = (datasetStore.variable?.max ?? 100) * COLOR_MAX_PCT; |
| <component | ||
| :is="mapComponent" | ||
| :year="year" | ||
| :step="step" | ||
| :display-raster="displayRaster" | ||
| :circle-to-polygon-edges="circleToPolygonEdges" | ||
| @mapReady="emit('mapReady', $event)" | ||
| @stepReady="emit('stepReady')" | ||
| /> |
There was a problem hiding this comment.
Map.client.vue always forwards a step prop to the underlying map component, but LeafletMap.client.vue still declares year (not step). If the app runs with the Leaflet engine (via config/query), the year/step selection will stop working and Vue will treat step as an unknown attribute. Consider mapping step -> year for the Leaflet component (or updating LeafletMap to accept step too).
| @@ -71,9 +73,13 @@ definePageMeta({ | |||
| const route = useRoute(); | |||
| const appStore = useAppStore(); | |||
| const datasetStore = useDatasetStore(); | |||
| const analysisStore = useAnalysisStore(); | |||
| const messageStore = useMessagesStore(); | |||
| const legacyActions = useLegacyStoreActions(); | |||
|
|
|||
| const yearSelected = ref(1500); | |||
| const stepSelected = ref(1500); | |||
| const mapRef = ref(); | |||
| const timeSeriesPlotRef = ref(); | |||
| let stopTimeSeriesWatch: (() => void) | null = null; | |||
|
|
|||
| const hasValidStudyArea = computed(() => datasetStore.hasGeoJson); | |||
| @@ -84,24 +90,12 @@ const analyzeLocation = computed(() => ({ | |||
| const isLoadingMetadata = computed(() => datasetStore.metadata == null); | |||
| const traces = computed(() => [{ ...datasetStore.filteredTimeSeries(), type: "scatter" }]); | |||
|
|
|||
| function setYear(year: number) { | |||
| yearSelected.value = year; | |||
| function setStep(step: number) { | |||
| stepSelected.value = step; | |||
| } | |||
|
|
|||
| async function requestJson(url: string, options: RequestInit = {}) { | |||
| const response = await fetch(url, { | |||
| headers: { "Content-Type": "application/json", ...(options.headers || {}) }, | |||
| ...options, | |||
| }); | |||
| if (!response.ok) { | |||
| const responseData = await response | |||
| .json() | |||
| .catch(() => ({ detail: [{ msg: response.statusText }] })); | |||
| const error: any = new Error(`Request failed with status ${response.status}`); | |||
| error.response = { status: response.status, data: responseData }; | |||
| throw error; | |||
| } | |||
| return response.json(); | |||
| function onStepReady() { | |||
| timeSeriesPlotRef.value?.advanceAnimation(); | |||
| } | |||
|
|
|||
| async function updateTimeSeries(data: any) { | |||
| @@ -111,10 +105,12 @@ async function updateTimeSeries(data: any) { | |||
| } | |||
| datasetStore.setTimeSeriesLoading(); | |||
| try { | |||
| const response = await requestJson(TIMESERIES_ENDPOINT, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| }); | |||
| const varId = route.params.variable as string; | |||
| console.log("Requesting time series with varId:", varId); | |||
| const jobId = datasetStore.jobIds?.[varId]; | |||
| // console.log("Requesting time series with data:", data, "and jobId:", jobId); | |||
| const {newJobId, response} = await legacyActions.resolveTimeSeries(jobId, data); | |||
There was a problem hiding this comment.
There are a few unused/leftover items here that will likely trip linting and add noise: analysisStore is imported/instantiated but never used, mapRef is declared but unused, and there is a console.log in the request path. Please remove unused refs/imports and the debug log (and update the existing visualize page unit test expectation for displayRaster, since it is now true).
| const stops = this._colors | ||
| .slice() | ||
| .reverse() | ||
| .map((c, i) => { | ||
| const offset = ((i / (this._colors.length - 1)) * 100).toFixed(1); | ||
| return `<stop offset="${offset}%" stop-color="${c}"/>`; | ||
| }) | ||
| .join(""); | ||
|
|
There was a problem hiding this comment.
The gradient stop offset calculation divides by (this._colors.length - 1). If colors is empty or has a single entry, this becomes a divide-by-zero and will render NaN%/Infinity% offsets. Add a guard (e.g., handle 0/1-color cases explicitly) before building stops.
| const stops = this._colors | |
| .slice() | |
| .reverse() | |
| .map((c, i) => { | |
| const offset = ((i / (this._colors.length - 1)) * 100).toFixed(1); | |
| return `<stop offset="${offset}%" stop-color="${c}"/>`; | |
| }) | |
| .join(""); | |
| const stops = (() => { | |
| if (this._colors.length === 0) return ""; | |
| if (this._colors.length === 1) { | |
| const color = this._colors[0]; | |
| return `<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${color}"/>`; | |
| } | |
| return this._colors | |
| .slice() | |
| .reverse() | |
| .map((c, i) => { | |
| const offset = ((i / (this._colors.length - 1)) * 100).toFixed(1); | |
| return `<stop offset="${offset}%" stop-color="${c}"/>`; | |
| }) | |
| .join(""); | |
| })(); |
| watch( | ||
| () => props.step, | ||
| (step: number) => { | ||
| updateRasterLayer(step); | ||
| } | ||
| ) |
There was a problem hiding this comment.
This watch(() => props.step, ...) call is missing the terminating );. With the repo's semi: ["error", "always"] lint rule, this will likely fail linting (and it’s inconsistent with other watchers in the file).
| addCogRasterLayer(props.step); | ||
| } | ||
| } | ||
| ) |
There was a problem hiding this comment.
This watch(cogBaseUrl, ...) call is also missing the terminating );, which is inconsistent with the rest of the file and can violate the semi lint rule.
| ) | |
| ); |
| // if no existing job or error status is 404 or 422 (job if not found or invalid), submit a new request | ||
| const newJobId = await submitTimeSeriesRequest(requestData); | ||
| const response = await pollTimeSeriesStatus(newJobId); | ||
| const result = response.result |
There was a problem hiding this comment.
Missing semicolon: const result = response.result should end with ; to satisfy the repo’s semi: ["error", "always"] lint rule.
| const result = response.result | |
| const result = response.result; |
resolveTimeSerieslogic