Files
sejeteralo/frontend/app/components/charts/VoteOverlayChart.vue
Yvv 5dc42af33e Add interactive citizen page with sidebar, display settings, and adaptive CSS
Major rework of the citizen-facing page:
- Chart + sidebar layout (auth/vote/countdown in right sidebar)
- DisplaySettings component (font size, chart density, color palettes)
- Adaptive CSS with clamp() throughout, responsive breakpoints at 480/768/1024
- Baseline charts zoomed on first tier for small consumption detail
- Marginal price chart with dual Y-axes (foyers left, €/m³ right)
- Key metrics banner (5 columns: recettes, palier, prix palier, prix médian, mon prix)
- Client-side p0/impacts computation, draggable median price bar
- Household dots toggle, vote overlay curves
- Auth returns volume_m3, vote captures submitted_at
- Cleaned header nav (removed Accueil/Super Admin for public visitors)
- Terminology: foyer for bills, électeur for votes
- 600m³ added to impact reference volumes
- Realistic seed votes (50 votes, 3 profiles)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:00:22 +01:00

130 lines
3.8 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="#e2e8f0" 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="#e2e8f0" 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="#1e40af" stroke-width="4" />
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="4" />
</g>
</g>
<!-- Axis labels -->
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b">
Volume ()
</text>
<text :x="6" :y="12" font-size="11" fill="#64748b">/</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: white;
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>