Initial commit: SejeteralO water tarification platform
Full-stack app for participatory water pricing using Bezier curves. - Backend: FastAPI + SQLAlchemy + SQLite with JWT auth - Frontend: Nuxt 4 + TypeScript with interactive SVG editor - Math engine: cubic Bezier tarification with Cardano solver - Admin: commune management, household import, vote monitoring, CMS - Citizen: interactive curve editor, vote submission - Docker-compose deployment ready Includes fixes for: - Impact table snake_case/camelCase property mismatch - CMS content backend API + frontend editor (was stub) - Admin route protection middleware - Public content display on commune page - Vote confirmation page link fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
112
frontend/app/components/charts/VoteOverlayChart.vue
Normal file
112
frontend/app/components/charts/VoteOverlayChart.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="overlay-chart">
|
||||
<svg :viewBox="`0 0 ${svgW} ${svgH}`" preserveAspectRatio="xMidYMid meet">
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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" opacity="0.3" />
|
||||
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1" opacity="0.3" />
|
||||
</g>
|
||||
|
||||
<!-- Median curve (if available) -->
|
||||
<g v-if="medianVote">
|
||||
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="3" />
|
||||
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="3" />
|
||||
</g>
|
||||
|
||||
<!-- Axis labels -->
|
||||
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b">
|
||||
Volume (m³)
|
||||
</text>
|
||||
<text :x="6" :y="12" font-size="11" fill="#64748b">€/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;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user