- Add 2 dark palettes (Nuit, Ocean) to DisplaySettings with full SVG theme tokens — all hardcoded SVG colors (grids, legends, text fills, pills, dot strokes, drag handles) replaced with reactive bindings - Update scoped CSS to use var(--color-*) and var(--svg-*) throughout - Add Woodpecker CI pipeline (.woodpecker.yml): build → docker push → deploy - Add multi-stage Dockerfiles for backend (Python) and frontend (Nuxt) - Add production docker-compose with Traefik labels + dev override - Remove old single-stage Dockerfiles and root docker-compose.yml - Update Makefile with docker-dev target - Exclude data files (pdf, xls, ipynb) from git Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
130 lines
3.9 KiB
Vue
130 lines
3.9 KiB
Vue
<template>
|
|
<div class="overlay-chart">
|
|
<svg :viewBox="`0 0 ${svgW} ${svgH}`" preserveAspectRatio="xMidYMid meet">
|
|
<defs>
|
|
<clipPath id="overlay-clip">
|
|
<rect :x="margin.left" :y="margin.top" :width="plotW" :height="plotH" />
|
|
</clipPath>
|
|
</defs>
|
|
|
|
<!-- Grid -->
|
|
<g>
|
|
<line
|
|
v-for="v in gridVolumes"
|
|
:key="'gv' + v"
|
|
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
|
|
stroke="var(--svg-grid)" stroke-width="0.5"
|
|
/>
|
|
<line
|
|
v-for="p in gridPrices"
|
|
:key="'gp' + p"
|
|
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
|
|
stroke="var(--svg-grid)" stroke-width="0.5"
|
|
/>
|
|
</g>
|
|
|
|
<!-- Clipped curves -->
|
|
<g clip-path="url(#overlay-clip)">
|
|
<!-- Vote curves (semi-transparent) -->
|
|
<g v-for="(vote, i) in votes" :key="i">
|
|
<path :d="getVotePath(vote, 1)" fill="none" stroke="#3b82f6" stroke-width="1.5" opacity="0.4" />
|
|
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1.5" opacity="0.4" />
|
|
</g>
|
|
|
|
<!-- Median curve (if available) -->
|
|
<g v-if="medianVote">
|
|
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="var(--color-primary)" stroke-width="4" />
|
|
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#ef4444" stroke-width="4" />
|
|
</g>
|
|
</g>
|
|
|
|
<!-- Axis labels -->
|
|
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="var(--svg-text-light)">
|
|
Volume (m³)
|
|
</text>
|
|
<text :x="6" :y="12" font-size="11" fill="var(--svg-text-light)">€/m³</text>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { paramsToControlPoints } from '~/utils/bezier-math'
|
|
|
|
const props = defineProps<{
|
|
votes: Array<{ vinf: number; a: number; b: number; c: number; d: number; e: number; computed_p0?: number }>
|
|
slug: string
|
|
}>()
|
|
|
|
const api = useApi()
|
|
|
|
const svgW = 600
|
|
const svgH = 300
|
|
const margin = { top: 15, right: 15, bottom: 25, left: 30 }
|
|
const plotW = svgW - margin.left - margin.right
|
|
const plotH = svgH - margin.top - margin.bottom
|
|
|
|
const vmax = ref(2100)
|
|
const pmax = ref(20)
|
|
const medianVote = ref<any>(null)
|
|
|
|
function toSvgX(v: number): number {
|
|
return margin.left + (v / vmax.value) * plotW
|
|
}
|
|
|
|
function toSvgY(p: number): number {
|
|
return margin.top + plotH - (p / pmax.value) * plotH
|
|
}
|
|
|
|
const gridVolumes = computed(() => {
|
|
const step = Math.ceil(vmax.value / 7 / 100) * 100
|
|
const arr = []
|
|
for (let v = step; v < vmax.value; v += step) arr.push(v)
|
|
return arr
|
|
})
|
|
|
|
const gridPrices = computed(() => {
|
|
const step = Math.ceil(pmax.value / 5)
|
|
const arr = []
|
|
for (let p = step; p < pmax.value; p += step) arr.push(p)
|
|
return arr
|
|
})
|
|
|
|
function getVotePath(vote: any, tier: number): string {
|
|
const p0 = vote.computed_p0 || 5
|
|
const cp = paramsToControlPoints(vote.vinf, vmax.value, pmax.value, p0, vote.a, vote.b, vote.c, vote.d, vote.e)
|
|
|
|
if (tier === 1) {
|
|
return `M ${toSvgX(cp.p1.x)} ${toSvgY(cp.p1.y)} C ${toSvgX(cp.p2.x)} ${toSvgY(cp.p2.y)}, ${toSvgX(cp.p3.x)} ${toSvgY(cp.p3.y)}, ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)}`
|
|
} else {
|
|
return `M ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)} C ${toSvgX(cp.p5.x)} ${toSvgY(cp.p5.y)}, ${toSvgX(cp.p6.x)} ${toSvgY(cp.p6.y)}, ${toSvgX(cp.p7.x)} ${toSvgY(cp.p7.y)}`
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const params = await api.get<any>(`/communes/${props.slug}/params`)
|
|
vmax.value = params.vmax
|
|
pmax.value = params.pmax
|
|
} catch {}
|
|
|
|
try {
|
|
medianVote.value = await api.get(`/communes/${props.slug}/votes/median`)
|
|
} catch {}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.overlay-chart svg {
|
|
width: 100%;
|
|
height: auto;
|
|
background: var(--color-surface);
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.overlay-chart svg path {
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
</style>
|