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:
518
frontend/app/components/BezierEditor.vue
Normal file
518
frontend/app/components/BezierEditor.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="bezier-editor">
|
||||
<div class="editor-layout">
|
||||
<!-- SVG Canvas -->
|
||||
<div class="editor-canvas card">
|
||||
<svg
|
||||
ref="svgRef"
|
||||
:viewBox="`0 0 ${svgW} ${svgH}`"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
@touchmove.prevent="onTouchMove"
|
||||
@touchend="onMouseUp"
|
||||
>
|
||||
<!-- Grid -->
|
||||
<g class="grid-lines">
|
||||
<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"
|
||||
/>
|
||||
<!-- Axis labels -->
|
||||
<text
|
||||
v-for="v in gridVolumes"
|
||||
:key="'lv' + v"
|
||||
:x="toSvgX(v)" :y="svgH - 2"
|
||||
text-anchor="middle" font-size="10" fill="#94a3b8"
|
||||
>{{ v }}</text>
|
||||
<text
|
||||
v-for="p in gridPrices"
|
||||
:key="'lp' + p"
|
||||
:x="4" :y="toSvgY(p) + 3"
|
||||
font-size="10" fill="#94a3b8"
|
||||
>{{ p }}</text>
|
||||
</g>
|
||||
|
||||
<!-- Control point tangent lines -->
|
||||
<g class="tangent-lines">
|
||||
<line :x1="toSvgX(cp.p1.x)" :y1="toSvgY(cp.p1.y)" :x2="toSvgX(cp.p2.x)" :y2="toSvgY(cp.p2.y)"
|
||||
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="toSvgX(cp.p3.x)" :y1="toSvgY(cp.p3.y)" :x2="toSvgX(cp.p4.x)" :y2="toSvgY(cp.p4.y)"
|
||||
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="toSvgX(cp.p4.x)" :y1="toSvgY(cp.p4.y)" :x2="toSvgX(cp.p5.x)" :y2="toSvgY(cp.p5.y)"
|
||||
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="toSvgX(cp.p6.x)" :y1="toSvgY(cp.p6.y)" :x2="toSvgX(cp.p7.x)" :y2="toSvgY(cp.p7.y)"
|
||||
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
|
||||
</g>
|
||||
|
||||
<!-- Bézier curves -->
|
||||
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
|
||||
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2.5" />
|
||||
|
||||
<!-- Inflection point lines -->
|
||||
<line :x1="toSvgX(params.vinf)" :y1="toSvgY(0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
|
||||
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="toSvgX(0)" :y1="toSvgY(localP0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
|
||||
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||
|
||||
<!-- p0 label -->
|
||||
<text :x="toSvgX(0) + 25" :y="toSvgY(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
|
||||
p₀ = {{ localP0.toFixed(2) }} €/m³
|
||||
</text>
|
||||
|
||||
<!-- Draggable control points -->
|
||||
<circle
|
||||
v-for="(point, key) in draggablePoints"
|
||||
:key="key"
|
||||
:cx="toSvgX(point.x)"
|
||||
:cy="toSvgY(point.y)"
|
||||
:r="dragging === key ? 8 : 6"
|
||||
:fill="pointColors[key]"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
style="cursor: grab;"
|
||||
@mousedown.prevent="startDrag(key, $event)"
|
||||
@touchstart.prevent="startDragTouch(key, $event)"
|
||||
/>
|
||||
|
||||
<!-- Point labels -->
|
||||
<text
|
||||
v-for="(point, key) in draggablePoints"
|
||||
:key="'label-' + key"
|
||||
:x="toSvgX(point.x) + 10"
|
||||
:y="toSvgY(point.y) - 10"
|
||||
font-size="11"
|
||||
:fill="pointColors[key]"
|
||||
font-weight="500"
|
||||
>{{ pointLabels[key] }}</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Right panel -->
|
||||
<div class="editor-panel">
|
||||
<!-- Parameters display -->
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<h3 style="margin-bottom: 0.75rem;">Paramètres</h3>
|
||||
<div class="param-grid">
|
||||
<div class="param-item">
|
||||
<span class="param-label">vinf</span>
|
||||
<span class="param-value">{{ params.vinf.toFixed(0) }} m³</span>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="param-label">a</span>
|
||||
<span class="param-value">{{ params.a.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="param-label">b</span>
|
||||
<span class="param-value">{{ params.b.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="param-label">c</span>
|
||||
<span class="param-value">{{ params.c.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="param-label">d</span>
|
||||
<span class="param-value">{{ params.d.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="param-label">e</span>
|
||||
<span class="param-value">{{ params.e.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-item" style="grid-column: span 2;">
|
||||
<span class="param-label">p₀ (prix inflexion)</span>
|
||||
<span class="param-value" style="font-size: 1.25rem;">{{ localP0.toFixed(2) }} €/m³</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impact table -->
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<h3 style="margin-bottom: 0.75rem;">Impact par volume</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Volume</th>
|
||||
<th>Ancien prix</th>
|
||||
<th>Nouveau (RP)</th>
|
||||
<th>Nouveau (RS)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="imp in impacts" :key="imp.volume">
|
||||
<td>{{ imp.volume }} m³</td>
|
||||
<td>{{ imp.oldPrice.toFixed(2) }} €</td>
|
||||
<td :class="imp.newPriceRP > imp.oldPrice ? 'text-up' : 'text-down'">
|
||||
{{ imp.newPriceRP.toFixed(2) }} €
|
||||
</td>
|
||||
<td :class="imp.newPriceRS > imp.oldPrice ? 'text-up' : 'text-down'">
|
||||
{{ imp.newPriceRS.toFixed(2) }} €
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Submit vote -->
|
||||
<button class="btn btn-primary" style="width: 100%;" @click="submitVote" :disabled="submitting">
|
||||
<span v-if="submitting" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
||||
Soumettre mon vote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computeP0, generateCurve, computeImpacts,
|
||||
paramsToControlPoints, controlPointsToParams,
|
||||
type HouseholdData, type ImpactRow, type ControlPoints,
|
||||
} from '~/utils/bezier-math'
|
||||
|
||||
const props = defineProps<{ communeSlug: string }>()
|
||||
const emit = defineEmits<{ 'vote-submitted': [] }>()
|
||||
const api = useApi()
|
||||
|
||||
// SVG dimensions
|
||||
const svgW = 600
|
||||
const svgH = 400
|
||||
const margin = { top: 20, right: 20, bottom: 30, left: 35 }
|
||||
const plotW = svgW - margin.left - margin.right
|
||||
const plotH = svgH - margin.top - margin.bottom
|
||||
|
||||
// Commune tariff params (fixed by admin)
|
||||
const vmax = ref(2100)
|
||||
const pmax = ref(20)
|
||||
const recettes = ref(75000)
|
||||
const abop = ref(100)
|
||||
const abos = ref(100)
|
||||
const households = ref<HouseholdData[]>([])
|
||||
|
||||
// Citizen-adjustable params
|
||||
const params = reactive({
|
||||
vinf: 1050,
|
||||
a: 0.5,
|
||||
b: 0.5,
|
||||
c: 0.5,
|
||||
d: 0.5,
|
||||
e: 0.5,
|
||||
})
|
||||
|
||||
// Computed
|
||||
const localP0 = ref(0)
|
||||
const impacts = ref<ImpactRow[]>([])
|
||||
const submitting = ref(false)
|
||||
|
||||
const cp = computed<ControlPoints>(() =>
|
||||
paramsToControlPoints(params.vinf, vmax.value, pmax.value, localP0.value, params.a, params.b, params.c, params.d, params.e)
|
||||
)
|
||||
|
||||
const draggablePoints = computed(() => ({
|
||||
p2: cp.value.p2,
|
||||
p3: cp.value.p3,
|
||||
p4: cp.value.p4,
|
||||
p5: cp.value.p5,
|
||||
p6: cp.value.p6,
|
||||
}))
|
||||
|
||||
const pointColors: Record<string, string> = {
|
||||
p2: '#3b82f6',
|
||||
p3: '#3b82f6',
|
||||
p4: '#8b5cf6',
|
||||
p5: '#ef4444',
|
||||
p6: '#ef4444',
|
||||
}
|
||||
|
||||
const pointLabels: Record<string, string> = {
|
||||
p2: 'P₂ (a)',
|
||||
p3: 'P₃ (b)',
|
||||
p4: 'P₄ (vinf)',
|
||||
p5: 'P₅ (c)',
|
||||
p6: 'P₆ (d,e)',
|
||||
}
|
||||
|
||||
// Grid
|
||||
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
|
||||
})
|
||||
|
||||
// Coordinate transforms
|
||||
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
|
||||
}
|
||||
|
||||
function fromSvgX(sx: number): number {
|
||||
return ((sx - margin.left) / plotW) * vmax.value
|
||||
}
|
||||
|
||||
function fromSvgY(sy: number): number {
|
||||
return ((margin.top + plotH - sy) / plotH) * pmax.value
|
||||
}
|
||||
|
||||
// Bézier path generation
|
||||
const tier1Path = computed(() => {
|
||||
const c = cp.value
|
||||
return `M ${toSvgX(c.p1.x)} ${toSvgY(c.p1.y)} C ${toSvgX(c.p2.x)} ${toSvgY(c.p2.y)}, ${toSvgX(c.p3.x)} ${toSvgY(c.p3.y)}, ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)}`
|
||||
})
|
||||
|
||||
const tier2Path = computed(() => {
|
||||
const c = cp.value
|
||||
return `M ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)} C ${toSvgX(c.p5.x)} ${toSvgY(c.p5.y)}, ${toSvgX(c.p6.x)} ${toSvgY(c.p6.y)}, ${toSvgX(c.p7.x)} ${toSvgY(c.p7.y)}`
|
||||
})
|
||||
|
||||
// Drag handling
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
const dragging = ref<string | null>(null)
|
||||
|
||||
function getSvgPoint(event: MouseEvent | Touch): { x: number; y: number } {
|
||||
if (!svgRef.value) return { x: 0, y: 0 }
|
||||
const rect = svgRef.value.getBoundingClientRect()
|
||||
const scaleX = svgW / rect.width
|
||||
const scaleY = svgH / rect.height
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(key: string, event: MouseEvent) {
|
||||
dragging.value = key
|
||||
}
|
||||
|
||||
function startDragTouch(key: string, event: TouchEvent) {
|
||||
dragging.value = key
|
||||
}
|
||||
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
if (!dragging.value) return
|
||||
handleDrag(getSvgPoint(event))
|
||||
}
|
||||
|
||||
function onTouchMove(event: TouchEvent) {
|
||||
if (!dragging.value || !event.touches[0]) return
|
||||
handleDrag(getSvgPoint(event.touches[0]))
|
||||
}
|
||||
|
||||
function handleDrag(svgPoint: { x: number; y: number }) {
|
||||
const v = Math.max(0, Math.min(vmax.value, fromSvgX(svgPoint.x)))
|
||||
const p = Math.max(0, Math.min(pmax.value, fromSvgY(svgPoint.y)))
|
||||
|
||||
switch (dragging.value) {
|
||||
case 'p2': // vertical only → a
|
||||
params.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
|
||||
break
|
||||
case 'p3': // horizontal only → b
|
||||
params.b = params.vinf > 0 ? Math.max(0, Math.min(1, v / params.vinf)) : 0.5
|
||||
break
|
||||
case 'p4': // horizontal → vinf
|
||||
params.vinf = Math.max(1, Math.min(vmax.value - 1, v))
|
||||
break
|
||||
case 'p5': { // horizontal only → c
|
||||
const wmax = vmax.value - params.vinf
|
||||
params.c = wmax > 0 ? Math.max(0, Math.min(1, (v - params.vinf) / wmax)) : 0.5
|
||||
break
|
||||
}
|
||||
case 'p6': { // 2D → d, e
|
||||
const wmax = vmax.value - params.vinf
|
||||
const qmax = pmax.value - localP0.value
|
||||
|
||||
// e from y
|
||||
params.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
|
||||
|
||||
// d from x: x6 = vinf + wmax*(1-d+cd) => d = (1 - (x6-vinf)/wmax)/(1-c)
|
||||
if (wmax > 0 && Math.abs(1 - params.c) > 1e-10) {
|
||||
const ratio = (v - params.vinf) / wmax
|
||||
params.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - params.c)))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
recalculate()
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (dragging.value) {
|
||||
dragging.value = null
|
||||
debouncedServerCompute()
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate locally
|
||||
function recalculate() {
|
||||
if (households.value.length === 0) return
|
||||
|
||||
localP0.value = computeP0(
|
||||
households.value, recettes.value, abop.value, abos.value,
|
||||
params.vinf, vmax.value, pmax.value,
|
||||
params.a, params.b, params.c, params.d, params.e,
|
||||
)
|
||||
|
||||
const result = computeImpacts(
|
||||
households.value, recettes.value, abop.value, abos.value,
|
||||
params.vinf, vmax.value, pmax.value,
|
||||
params.a, params.b, params.c, params.d, params.e,
|
||||
)
|
||||
impacts.value = result.impacts
|
||||
}
|
||||
|
||||
// Debounced server compute
|
||||
let serverTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
function debouncedServerCompute() {
|
||||
if (serverTimeout) clearTimeout(serverTimeout)
|
||||
serverTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const result = await api.post<any>('/tariff/compute', {
|
||||
commune_slug: props.communeSlug,
|
||||
vinf: params.vinf,
|
||||
a: params.a,
|
||||
b: params.b,
|
||||
c: params.c,
|
||||
d: params.d,
|
||||
e: params.e,
|
||||
})
|
||||
// Use authoritative server p0
|
||||
localP0.value = result.p0
|
||||
impacts.value = result.impacts.map((imp: any) => ({
|
||||
volume: imp.volume,
|
||||
oldPrice: imp.old_price,
|
||||
newPriceRP: imp.new_price_rp,
|
||||
newPriceRS: imp.new_price_rs,
|
||||
}))
|
||||
} catch (e) {
|
||||
// Silently fall back to client-side calculation
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Submit vote
|
||||
async function submitVote() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.post(`/communes/${props.communeSlug}/votes`, {
|
||||
vinf: params.vinf,
|
||||
a: params.a,
|
||||
b: params.b,
|
||||
c: params.c,
|
||||
d: params.d,
|
||||
e: params.e,
|
||||
})
|
||||
emit('vote-submitted')
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Erreur lors de la soumission du vote')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load commune params
|
||||
const communeParams = await api.get<any>(`/communes/${props.communeSlug}/params`)
|
||||
vmax.value = communeParams.vmax
|
||||
pmax.value = communeParams.pmax
|
||||
recettes.value = communeParams.recettes
|
||||
abop.value = communeParams.abop
|
||||
abos.value = communeParams.abos
|
||||
params.vinf = communeParams.vmax / 2
|
||||
|
||||
// Load household stats (we need volumes for p0 calculation)
|
||||
// For client-side compute, we fetch stats and create a simplified model
|
||||
const stats = await api.get<any>(`/communes/${props.communeSlug}/households/stats`)
|
||||
|
||||
// Create representative household distribution for client-side compute
|
||||
// (simplified: use average volumes by status)
|
||||
const rsCount = stats.rs_count || 0
|
||||
const rpCount = stats.rp_count || 0
|
||||
const proCount = stats.pro_count || 0
|
||||
const avgVol = stats.avg_volume || 90
|
||||
|
||||
const hh: HouseholdData[] = []
|
||||
for (let i = 0; i < rsCount; i++) hh.push({ volume_m3: avgVol, status: 'RS' })
|
||||
for (let i = 0; i < rpCount; i++) hh.push({ volume_m3: avgVol, status: 'RP' })
|
||||
for (let i = 0; i < proCount; i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
|
||||
households.value = hh
|
||||
|
||||
// Initial server compute for accurate p0
|
||||
recalculate()
|
||||
debouncedServerCompute()
|
||||
} catch (e) {
|
||||
console.error('Error loading commune data:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.editor-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-canvas {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-canvas svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.param-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-up { color: #dc2626; }
|
||||
.text-down { color: #059669; }
|
||||
</style>
|
||||
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