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>
519 lines
16 KiB
Vue
519 lines
16 KiB
Vue
<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>
|