diff --git a/src/lib/colors/palettes.ts b/src/lib/colors/palettes.ts index 8810a3a..5e1b864 100644 --- a/src/lib/colors/palettes.ts +++ b/src/lib/colors/palettes.ts @@ -50,6 +50,9 @@ export type PaletteId = string; export const DEFAULT_PALETTE_ID: PaletteId = 'thermal'; +/** The solid palette a non-numeric column falls back to. */ +export const DEFAULT_SOLID_PALETTE_ID: PaletteId = 'solid_blue'; + /** How many steps a cached colour table holds. */ const TABLE_SIZE = 256; diff --git a/src/lib/components/legend/legend-defaults.ts b/src/lib/components/legend/legend-defaults.ts index c79b5a3..167c14c 100644 --- a/src/lib/components/legend/legend-defaults.ts +++ b/src/lib/components/legend/legend-defaults.ts @@ -1,15 +1,2 @@ -/** - * The default range of the map legend. - * - * These live in their own module, and not in `Legend.svelte`, because - * `MapViewController` needs them too. A `.svelte.ts` controller that imports a - * constant from a component drags the whole component into its module graph. - * - * The range is deliberately wide. The controller replaces it with the real range - * of the column as soon as it paints one. See `createLayer`. - */ -export const SCALE_DEFAULT_MIN = -1000; -export const SCALE_DEFAULT_MAX = 1000; - /** How many blocks the legend strip is built from. */ export const COLOR_SCALE_BLIPS = 100; diff --git a/src/lib/components/legend/legend.svelte b/src/lib/components/legend/legend.svelte index 5b1e64d..f50631b 100644 --- a/src/lib/components/legend/legend.svelte +++ b/src/lib/components/legend/legend.svelte @@ -14,21 +14,36 @@ import { Label } from '../ui/label'; import PalettePicker from '@/components/palette/PalettePicker.svelte'; import { loadColormaps, samplePalette } from '@/colors/palettes'; - import { COLOR_SCALE_BLIPS, SCALE_DEFAULT_MAX, SCALE_DEFAULT_MIN } from './legend-defaults'; + import { COLOR_SCALE_BLIPS } from './legend-defaults'; import { onMount } from 'svelte'; let { - colorScaleMin = $bindable(SCALE_DEFAULT_MIN), - colorScaleMax = $bindable(SCALE_DEFAULT_MAX), + colorScaleMin = $bindable(null), + colorScaleMax = $bindable(null), + autoColorScaleMin, + autoColorScaleMax, palette = $bindable(''), paletteReverse = $bindable(false) }: { - colorScaleMin?: number; - colorScaleMax?: number; + /** Null shows the field empty ("auto") and paints with the matching auto bound. */ + colorScaleMin?: number | null; + colorScaleMax?: number | null; + /** The selected column's actual min/max, for the strip and for "auto". */ + autoColorScaleMin: number; + autoColorScaleMax: number; palette?: string; paletteReverse?: boolean; } = $props(); + /** An empty field means "auto", which falls back to the column's actual range. */ + function numberOrNull(value: string): number | null { + if (value.trim() === '') return null; + + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return parsed; + } + /** * Flips once the colormap file has loaded. The strip below reads the palette * synchronously, so it must be built again after the load. @@ -50,13 +65,16 @@ const strip = $derived.by(() => { void palettesLoaded; + const min = colorScaleMin ?? autoColorScaleMin; + const max = colorScaleMax ?? autoColorScaleMax; + const colors = samplePalette(palette, COLOR_SCALE_BLIPS, paletteReverse); - const span = colorScaleMax - colorScaleMin; + const span = max - min; const step = span / (COLOR_SCALE_BLIPS - 1); return colors.map((color, index) => ({ color, - value: Math.round((colorScaleMin + index * step) * 100) / 100 + value: Math.round((min + index * step) * 100) / 100 })); }); @@ -85,7 +103,9 @@ step="any" name="colorScaleMin" id="colorScaleMin" - bind:value={colorScaleMin} + value={colorScaleMin ?? ''} + placeholder="auto" + oninput={(event) => (colorScaleMin = numberOrNull(event.currentTarget.value))} title="The value at the left of the scale" /> @@ -97,7 +117,9 @@ step="any" name="colorScaleMax" id="colorScaleMax" - bind:value={colorScaleMax} + value={colorScaleMax ?? ''} + placeholder="auto" + oninput={(event) => (colorScaleMax = numberOrNull(event.currentTarget.value))} title="The value at the right of the scale" /> diff --git a/src/lib/components/query-builder/QueryBuilderTableSelector.svelte b/src/lib/components/query-builder/QueryBuilderTableSelector.svelte index 32f7097..e550e22 100644 --- a/src/lib/components/query-builder/QueryBuilderTableSelector.svelte +++ b/src/lib/components/query-builder/QueryBuilderTableSelector.svelte @@ -77,7 +77,7 @@ {/if}

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Description not available

diff --git a/src/lib/components/visualisation/MapViewController.svelte.ts b/src/lib/components/visualisation/MapViewController.svelte.ts index f6591c3..fc8095b 100644 --- a/src/lib/components/visualisation/MapViewController.svelte.ts +++ b/src/lib/components/visualisation/MapViewController.svelte.ts @@ -35,8 +35,14 @@ import { addToast } from '@/stores/toasts'; import { Utils } from '@/utils'; import type { Rendered } from '@/util-types'; import MapPopupContent from '@/components/MapPopupContent.svelte'; -import { SCALE_DEFAULT_MAX, SCALE_DEFAULT_MIN } from '@/components/legend/legend-defaults'; -import { DEFAULT_PALETTE_ID, getRgbTable, loadColormaps, paletteIndex } from '@/colors/palettes'; +import { + DEFAULT_PALETTE_ID, + DEFAULT_SOLID_PALETTE_ID, + getColormap, + getRgbTable, + loadColormaps, + paletteIndex +} from '@/colors/palettes'; import { detectCoordinateColumns } from '@/geo/coordinate-columns'; import { plottableColumns } from '@/plots/plot-data'; import type { MapCameraState, MapViewState } from '@/stores/stored-query'; @@ -100,8 +106,27 @@ export class MapViewController { availableColumnNames: string[] = $state([]); selectedDataColumnName: string | undefined = $state(undefined); - colorScaleMin: number = $state(SCALE_DEFAULT_MIN); - colorScaleMax: number = $state(SCALE_DEFAULT_MAX); + /** + * The legend's min and max inputs. Null means "auto": the Legend shows the + * field empty, and the map paints with {@link autoColorScaleMin} / + * {@link autoColorScaleMax} instead. See {@link effectiveColorScaleMin}. + */ + colorScaleMin: number | null = $state(null); + colorScaleMax: number | null = $state(null); + + /** + * The actual min and max of the selected column, recomputed by + * {@link showDataColumn} whenever the column changes. This is what "auto" + * resolves to, and what the Legend draws its strip from. + */ + autoColorScaleMin: number = $state(0); + autoColorScaleMax: number = $state(1); + + /** {@link colorScaleMin}, resolved to a paintable number. */ + readonly effectiveColorScaleMin = $derived(this.colorScaleMin ?? this.autoColorScaleMin); + /** {@link colorScaleMax}, resolved to a paintable number. */ + readonly effectiveColorScaleMax = $derived(this.colorScaleMax ?? this.autoColorScaleMax); + /** The id of the colormap that paints the points. See `colors/palettes.ts`. */ palette: string = $state(DEFAULT_PALETTE_ID); paletteReverse: boolean = $state(false); @@ -152,6 +177,19 @@ export class MapViewController { ); }); + /** The first of {@link dataColumnOptions} that holds numbers, for the default selection. */ + private readonly firstNumericColumnName = $derived.by(() => { + if (!this.entry) return undefined; + + const numericColumnNames = new Set( + plottableColumns(this.entry.table) + .filter((column) => column.kind === 'number') + .map((column) => column.name) + ); + + return this.dataColumnOptions.find((name) => numericColumnNames.has(name)); + }); + /** * The number of the newest run of this controller. The store runs one query at * a time, so a new run stops the run in flight. That older run rejects after @@ -250,8 +288,8 @@ export class MapViewController { this.viewBlockId = blockId; this.selectedDataColumnName = view?.dataColumn ?? undefined; - this.colorScaleMin = view?.colorScaleMin ?? SCALE_DEFAULT_MIN; - this.colorScaleMax = view?.colorScaleMax ?? SCALE_DEFAULT_MAX; + this.colorScaleMin = view?.colorScaleMin ?? null; + this.colorScaleMax = view?.colorScaleMax ?? null; // An unknown palette id is not repaired here. `getRgbTable` falls back to // the default for it, so an old record still paints. this.palette = view?.palette ?? DEFAULT_PALETTE_ID; @@ -423,15 +461,23 @@ export class MapViewController { return; } - // No valid selection: pick the first pickable column, so points render - // at once instead of leaving the user to open the dropdown first. + // No valid selection: pick the first numeric column, so the legend has a + // range to show at once. A text column stays in the list, but is not a + // good default because it cannot colour a point. this.renderedColumn = undefined; - this.selectedDataColumnName = this.dataColumnOptions[0]; + this.selectedDataColumnName = this.firstNumericColumnName ?? this.dataColumnOptions[0]; if (this.selectedDataColumnName) { + if (this.firstNumericColumnName) { + addToast({ + type: 'info', + message: + 'Make sure to select the data column you want to display in the map viewer. By default, the first (numerical) data column is displayed.' + }); + } await this.showDataColumn(true, fitCamera); } else { - addToast({ type: 'info', message: 'This query has no data column to display on the map.' }); + addToast({ type: 'warning', message: 'This query has no data column to display on the map.' }); } } @@ -468,16 +514,28 @@ export class MapViewController { if (!columnChanged && !force) return; this.renderedColumn = this.selectedDataColumnName; - // A new column has its own range. Reset to the defaults so `createLayer` - // recomputes the scale from this column's actual min/max. + // A new column has its own range. Clear any custom bounds, so the legend + // inputs go back to "auto", and refresh what "auto" resolves to. if (columnChanged) { - this.colorScaleMin = SCALE_DEFAULT_MIN; - this.colorScaleMax = SCALE_DEFAULT_MAX; + this.colorScaleMin = null; + this.colorScaleMax = null; + + // A gradient palette has no meaning on a non-numeric column. Fall back + // to solid blue, but leave an already-solid palette alone. + const isNumeric = plottableColumns(this.entry?.table ?? this.table!).some( + (column) => column.name === this.selectedDataColumnName && column.kind === 'number' + ); + + if (!isNumeric && !getColormap(this.palette).solid) { + this.palette = DEFAULT_SOLID_PALETTE_ID; + } + + await this.refreshAutoColorScale(); } this.isLoading = true; - this.layer = await this.createLayer(); + this.layer = this.createLayer(); this.overlay?.setProps({ layers: [this.layer] }); if (fitCamera) { @@ -499,26 +557,29 @@ export class MapViewController { this.map.fitBounds(bounds, { padding: { top: 50, bottom: 50, left: 50, right: 50 } }); } - private async createLayer() { + /** + * Recompute {@link autoColorScaleMin} / {@link autoColorScaleMax} from the + * selected column's actual range. Called once per column change; a value the + * user typed does not need this, since {@link effectiveColorScaleMin} only + * falls back to it while the field is empty. + */ + private async refreshAutoColorScale(): Promise { + if (!this.entry || !this.selectedDataColumnName) return; + + const minMax = await queryStore.minMax(this.entry, this.selectedDataColumnName); + + // Round the range for the legend inputs. A float column gives values like + // 27.856000900268555, which fills the field and tells the user nothing. + // Six digits keep every range this app shows apart. + this.autoColorScaleMin = roundForDisplay(minMax.min); + this.autoColorScaleMax = roundForDisplay(minMax.max); + } + + private createLayer() { if (!this.table) { throw new Error('Table is not loaded'); } - if ( - this.entry && - this.selectedDataColumnName && - this.colorScaleMin === SCALE_DEFAULT_MIN && - this.colorScaleMax === SCALE_DEFAULT_MAX - ) { - const minMax = await queryStore.minMax(this.entry, this.selectedDataColumnName); - - // Round the range for the legend inputs. A float column gives values - // like 27.856000900268555, which fills the field and tells the user - // nothing. Six digits keep every range this app shows apart. - this.colorScaleMin = roundForDisplay(minMax.min); - this.colorScaleMax = roundForDisplay(minMax.max); - } - return new GeoArrowScatterplotLayer({ id: 'geoarrow-points', data: this.table, @@ -536,8 +597,8 @@ export class MapViewController { getFillColor: [ this.palette, this.paletteReverse, - this.colorScaleMin, - this.colorScaleMax, + this.effectiveColorScaleMin, + this.effectiveColorScaleMax, this.selectedDataColumnName ] } @@ -566,10 +627,26 @@ export class MapViewController { if (!row) return [0, 0, 0, 0]; const value = row[this.selectedDataColumnName!]; - if (typeof value !== 'number' || isNaN(value)) return [0, 0, 0, 0]; + // A missing value hides the point, for a numeric column and a text one alike. + if (value === null || value === undefined || (typeof value === 'number' && isNaN(value))) { + return [0, 0, 0, 0]; + } + + // A solid palette paints every present value the same colour. This is also + // the only path for a non-numeric column, whose values cannot feed the scale. + if (getColormap(this.palette).solid) { + return [this.rgbTable[0], this.rgbTable[1], this.rgbTable[2], 192]; + } + + if (typeof value !== 'number') return [0, 0, 0, 0]; const offset = - paletteIndex(value, this.colorScaleMin, this.colorScaleMax, this.paletteReverse) * 3; + paletteIndex( + value, + this.effectiveColorScaleMin, + this.effectiveColorScaleMax, + this.paletteReverse + ) * 3; return [this.rgbTable[offset], this.rgbTable[offset + 1], this.rgbTable[offset + 2], 192]; } diff --git a/src/lib/stores/stored-query.ts b/src/lib/stores/stored-query.ts index 72081bb..f1b1c5d 100644 --- a/src/lib/stores/stored-query.ts +++ b/src/lib/stores/stored-query.ts @@ -53,8 +53,9 @@ export interface MapCameraState { export interface MapViewState { /** The column that the map paints. Null while the user picked none. */ dataColumn: string | null; - colorScaleMin: number; - colorScaleMax: number; + /** Null means "auto": the column's actual min/max. See `MapViewController`. */ + colorScaleMin: number | null; + colorScaleMax: number | null; /** * The id of the colormap that paints the points. See `colors/palettes.ts`. * An unknown id falls back to the default, so an old record still draws. diff --git a/src/routes/visualisations/map-viewer/+page.svelte b/src/routes/visualisations/map-viewer/+page.svelte index b51be76..d5f7049 100644 --- a/src/routes/visualisations/map-viewer/+page.svelte +++ b/src/routes/visualisations/map-viewer/+page.svelte @@ -255,6 +255,8 @@