Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ echo "VITE_FTW_INFERENCE_OUTPUT_URL=http://127.0.0.1:3000/" > .env.development

The app will be available at `http://localhost:5173`

### Benchmark page

Open [http://localhost:5173/benchmark](http://localhost:5173/benchmark) when your API supports FTW benchmark runs. Pick models and benchmark countries, then review scores and (optionally) the map: **gold** dashed outline = chip footprint, **green** = ground truth fields, **blue** = model predictions.

![Benchmark map: Corsica test chip — ground truth vs predictions](docs/images/benchmark-map-output.png)

## Alternative API Setup

If you prefer not to use conda, you can install the API manually:
Expand Down
Binary file added docs/images/benchmark-map-output.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
224 changes: 224 additions & 0 deletions src/components/BenchmarkMap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, shallowRef, nextTick } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import XYZ from 'ol/source/XYZ'
import GeoJSON from 'ol/format/GeoJSON'
import Feature from 'ol/Feature'
import { Fill, Stroke, Style } from 'ol/style'
import { createEmpty, extend as extendExtent, isEmpty } from 'ol/extent'
import type { Extent } from 'ol/extent'
import type { Feature as GeoJSONFeature, FeatureCollection } from 'geojson'

export type BenchmarkMapChip = {
chip_id: string
footprint: GeoJSONFeature
predictions: FeatureCollection
ground_truth: FeatureCollection
}

const props = defineProps<{
mapData: { chips: BenchmarkMapChip[] } | null
}>()

const mapRoot = ref<HTMLElement | null>(null)
const mapInstance = shallowRef<Map | null>(null)

const fmt = new GeoJSON({ dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857' })

const footprintStyle = new Style({
stroke: new Stroke({ color: '#fbbf24', width: 2, lineDash: [8, 4] }),
fill: new Fill({ color: 'rgba(251, 191, 36, 0.06)' }),
})
const gtStyle = new Style({
fill: new Fill({ color: 'rgba(34, 197, 94, 0.4)' }),
stroke: new Stroke({ color: '#16a34a', width: 2 }),
})
const predStyle = new Style({
fill: new Fill({ color: 'rgba(59, 130, 246, 0.4)' }),
stroke: new Stroke({ color: '#2563eb', width: 2 }),
})

function buildSourcesFromPayload(data: { chips: BenchmarkMapChip[] }) {
const footSource = new VectorSource()
const gtSource = new VectorSource()
const predSource = new VectorSource()
for (const c of data.chips) {
if (c.footprint) {
const parsed = fmt.readFeature(c.footprint)
const f = (Array.isArray(parsed) ? parsed[0] : parsed) as Feature
f.setStyle(footprintStyle)
footSource.addFeature(f)
}
if (c.ground_truth?.features?.length) {
const feats = fmt.readFeatures(c.ground_truth)
feats.forEach((f) => f.setStyle(gtStyle))
gtSource.addFeatures(feats)
}
if (c.predictions?.features?.length) {
const feats = fmt.readFeatures(c.predictions)
feats.forEach((f) => f.setStyle(predStyle))
predSource.addFeatures(feats)
}
}
return { footSource, gtSource, predSource }
}

function fitToSources(map: Map, sources: VectorSource[]) {
let ext: Extent = createEmpty()

Check failure on line 71 in src/components/BenchmarkMap.vue

View workflow job for this annotation

GitHub Actions / test-build

'ext' is never reassigned. Use 'const' instead
for (const s of sources) {
const le = s.getExtent()
if (!isEmpty(le)) extendExtent(ext, le)
}
if (!isEmpty(ext)) {
map.getView().fit(ext, { padding: [48, 48, 48, 48], maxZoom: 17, duration: 200 })
}
}

function ensureMap(): Map | null {
if (mapInstance.value) return mapInstance.value
const el = mapRoot.value
if (!el) return null
const base = new TileLayer({
source: new XYZ({
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attributions: '© OpenStreetMap contributors',
}),
Comment on lines +86 to +89
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

})
const m = new Map({
target: el,
layers: [base],
view: new View({ center: [0, 0], zoom: 2 }),
})
mapInstance.value = m
return m
}

function applyPayload() {
const map = mapInstance.value
if (!map) return
const layers = map.getLayers()
while (layers.getLength() > 1) {
layers.pop()
}
const data = props.mapData
if (!data?.chips?.length) return
const { footSource, gtSource, predSource } = buildSourcesFromPayload(data)
map.addLayer(
new VectorLayer({
source: footSource,
zIndex: 1,
}),
)
map.addLayer(
new VectorLayer({
source: gtSource,
zIndex: 2,
}),
)
map.addLayer(
new VectorLayer({
source: predSource,
zIndex: 3,
}),
)
fitToSources(map, [footSource, gtSource, predSource])
map.updateSize()
requestAnimationFrame(() => map.updateSize())
Comment on lines +129 to +130
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

}

async function syncMapToData() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this run multiple times for the same data? It's triggered by mounted and immediately when the watcher below fires. The nextTicks also look suspicious.

await nextTick()
if (!props.mapData?.chips?.length) {
return
}
if (!mapRoot.value) {
await nextTick()
}
const map = ensureMap()
if (!map) return
applyPayload()
}

watch(
() => props.mapData,
() => {
void syncMapToData()
},
{ deep: true, immediate: true, flush: 'post' },
)

onMounted(() => {
void syncMapToData()
})

onUnmounted(() => {
mapInstance.value?.setTarget(undefined)
mapInstance.value = null
})
</script>

<template>
<div class="benchmark-map-wrap">
<div
v-if="!mapData?.chips?.length"
class="benchmark-map-empty text-caption text-medium-emphasis"
>
No map geometry for this selection.
</div>
<!-- Keep host in DOM whenever parent may pass data on next tick; OL needs a real size. -->
<div v-show="mapData?.chips?.length" ref="mapRoot" class="benchmark-map" />
<div v-if="mapData?.chips?.length" class="legend text-caption">
<span><span class="swatch gt" /> Ground truth</span>
<span><span class="swatch pred" /> Model predictions</span>
<span><span class="swatch foot" /> Chip footprint</span>
</div>
</div>
</template>

<style scoped>
.benchmark-map-wrap {
position: relative;
}
.benchmark-map {
width: 100%;
height: 420px;
border-radius: 8px;
overflow: hidden;
background: #1a1a1a;
}
.benchmark-map-empty {
padding: 2rem;
text-align: center;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 0.5rem;
color: #aaa;
}
.legend .swatch {
display: inline-block;
width: 14px;
height: 10px;
margin-right: 6px;
border-radius: 2px;
vertical-align: middle;
}
.legend .gt {
background: rgba(34, 197, 94, 0.5);
border: 1px solid #16a34a;
}
.legend .pred {
background: rgba(59, 130, 246, 0.5);
border: 1px solid #2563eb;
}
.legend .foot {
background: rgba(251, 191, 36, 0.15);
border: 1px dashed #fbbf24;
}
</style>
64 changes: 64 additions & 0 deletions src/composables/useBenchmarkCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ref, shallowRef } from 'vue'
import { generateJWT } from '../functions/generate-jwt'

const base = import.meta.env.VITE_API_BASE_URL || '/v1/'
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We default to nothing/empty in all other places.


export interface BenchmarkCountry {
id: string
title: string
year: number
chips: number
train: number
validation: number
test: number
license: string
}

export interface ModelRow {
id: string
title: string
description?: string
}

const countries = shallowRef<BenchmarkCountry[]>([])
const models = shallowRef<ModelRow[]>([])
const loading = ref(false)
const error = ref<string | null>(null)

export async function loadBenchmarkCatalog(): Promise<void> {
loading.value = true
error.value = null
try {
const headers = {
Authorization: `Bearer ${generateJWT()}`,
}
const [cRes, mRes] = await Promise.all([
fetch(`${base}benchmarks/countries`, { headers }),
fetch(`${base}models`, { headers }),
])
if (!cRes.ok) {
throw new Error((await cRes.json()).detail || 'Failed to load benchmarks')
}
if (!mRes.ok) {
throw new Error((await mRes.json()).detail || 'Failed to load models')
}
const cJson = await cRes.json()
const mJson = await mRes.json()
countries.value = cJson.countries || []
models.value = mJson.models || []
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}

export function useBenchmarkCatalog() {
return {
countries,
models,
loading,
error,
loadBenchmarkCatalog,
}
}
5 changes: 5 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const router = createRouter({
name: 'map',
component: () => import('../views/MapView.vue'),
},
{
path: '/benchmark',
name: 'benchmark',
component: () => import('../views/BenchmarkView.vue'),
},
],
})

Expand Down
Loading
Loading