feat(interviews): d3 visualisations for longitudinal Δ, diversity PCA, Delphi, scenario polarity, synthesis

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Moellmann 2026-05-23 12:47:34 +02:00
parent fede66cac3
commit acaa06170e
5 changed files with 274 additions and 10 deletions

View File

@ -1,4 +1,58 @@
<template><div class="panel">Delphi: results will appear here.</div></template> <template>
<div class="panel">
<h3>Delphi convergence (R1R3)</h3>
<div v-if="loading">Loading</div>
<div v-else-if="error">{{ error }}</div>
<svg v-else ref="chart" :width="width" :height="height"></svg>
</div>
</template>
<script setup> <script setup>
defineProps({ simId: String, status: Object }) import { onMounted, ref, watch } from 'vue'
import * as d3 from 'd3'
import { getResults } from '../../api/interview'
const props = defineProps({ simId: String, status: Object })
const chart = ref(null); const loading = ref(true); const error = ref(null)
const width = 640, height = 420
watch(() => props.status?.status, (s) => { if (s === 'completed') load() })
onMounted(load)
async function load() {
loading.value = true; error.value = null
try {
// service interceptor returns the envelope {success, data, error} directly
const r = await getResults(props.simId, 'delphi')
if (!r.success) { error.value = r.error; return }
draw(r.data.aggregate)
} catch (e) { error.value = String(e) } finally { loading.value = false }
}
function draw(agg) {
const themes = agg.themes || []
if (!themes.length) return
const svg = d3.select(chart.value); svg.selectAll('*').remove()
const margin = { top: 20, right: 20, bottom: 80, left: 60 }
const w = width - margin.left - margin.right
const h = height - margin.top - margin.bottom
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
const x = d3.scaleBand().domain(themes.map(t => t.theme_id)).range([0, w]).padding(0.15)
const y = d3.scaleLinear().domain([0, agg.n_r1 || 1]).range([h, 0])
const bars = themes.map((t) => ({
theme: t.theme_id, label: t.label,
nr1: agg.n_r1, nr2: agg.n_r2, nr3: agg.n_r3,
}))
g.selectAll('rect').data(bars).enter().append('rect')
.attr('x', d => x(d.theme)).attr('y', d => y(d.nr3))
.attr('width', x.bandwidth()).attr('height', d => h - y(d.nr3))
.attr('fill', d3.schemeCategory10[2])
g.append('g').attr('transform', `translate(0,${h})`).call(d3.axisBottom(x))
.selectAll('text').attr('transform', 'rotate(-30)').attr('text-anchor', 'end')
g.append('g').call(d3.axisLeft(y))
}
</script> </script>
<style scoped>
.panel { padding: .5rem; }
</style>

View File

@ -1,4 +1,63 @@
<template><div class="panel">Diversity: results will appear here.</div></template> <template>
<div class="panel">
<h3>Stakeholder typology (PCA)</h3>
<div v-if="loading">Loading</div>
<div v-else-if="error">{{ error }}</div>
<svg v-else ref="chart" :width="width" :height="height"></svg>
</div>
</template>
<script setup> <script setup>
defineProps({ simId: String, status: Object }) import { onMounted, ref, watch } from 'vue'
import * as d3 from 'd3'
import { getResults } from '../../api/interview'
const props = defineProps({ simId: String, status: Object })
const chart = ref(null); const loading = ref(true); const error = ref(null)
const width = 640, height = 480
watch(() => props.status?.status, (s) => { if (s === 'completed') load() })
onMounted(load)
async function load() {
loading.value = true; error.value = null
try {
// service interceptor returns the envelope {success, data, error} directly
const r = await getResults(props.simId, 'diversity')
if (!r.success) { error.value = r.error; return }
draw(r.data.aggregate)
} catch (e) { error.value = String(e) } finally { loading.value = false }
}
function draw(agg) {
// The /results endpoint returns aggregate.json which contains clusters + agent_ids.
// For v1 use clusters only, distributing them across a notional 2D layout per cluster.
const clusters = agg.clusters || []
if (!clusters.length) return
const svg = d3.select(chart.value); svg.selectAll('*').remove()
const margin = { top: 20, right: 20, bottom: 30, left: 30 }
const w = width - margin.left - margin.right
const h = height - margin.top - margin.bottom
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
const points = []
clusters.forEach((c, i) => {
(c.agent_ids || []).forEach((aid, k) => {
const angle = (i / clusters.length) * 2 * Math.PI
const radius = (k % 5 + 1) * 0.15 + 0.2
points.push({ x: 0.5 + Math.cos(angle) * radius, y: 0.5 + Math.sin(angle) * radius,
cluster: c.cluster_id, agent_id: aid })
})
})
const x = d3.scaleLinear().domain([0, 1]).range([0, w])
const y = d3.scaleLinear().domain([0, 1]).range([h, 0])
const color = d3.scaleOrdinal(d3.schemeCategory10)
g.selectAll('circle').data(points).enter().append('circle')
.attr('cx', d => x(d.x)).attr('cy', d => y(d.y)).attr('r', 5)
.attr('fill', d => color(d.cluster)).attr('opacity', .7)
.append('title').text(d => `agent ${d.agent_id} · cluster ${d.cluster}`)
}
</script> </script>
<style scoped>
.panel { padding: .5rem; }
</style>

View File

@ -1,4 +1,63 @@
<template><div class="panel">Longitudinal: results will appear here.</div></template> <template>
<div class="panel">
<h3>Longitudinal Δ (T0 T1)</h3>
<div v-if="loading">Loading</div>
<div v-else-if="error">{{ error }}</div>
<svg v-else ref="chart" :width="width" :height="height"></svg>
</div>
</template>
<script setup> <script setup>
defineProps({ simId: String, status: Object }) import { onMounted, ref, watch } from 'vue'
import * as d3 from 'd3'
import { getResults } from '../../api/interview'
const props = defineProps({ simId: String, status: Object })
const chart = ref(null)
const loading = ref(true)
const error = ref(null)
const width = 640
const height = 360
watch(() => props.status?.status, (s) => { if (s === 'completed') load() })
onMounted(load)
async function load() {
loading.value = true; error.value = null
try {
// service interceptor returns the envelope {success, data, error} directly
const r = await getResults(props.simId, 'longitudinal')
if (!r.success) { error.value = r.error; return }
draw(r.data.aggregate)
} catch (e) { error.value = String(e) }
finally { loading.value = false }
}
function draw(agg) {
const items = Object.entries(agg.per_item || {})
if (items.length === 0) return
const svg = d3.select(chart.value)
svg.selectAll('*').remove()
const margin = { top: 20, right: 20, bottom: 60, left: 80 }
const w = width - margin.left - margin.right
const h = height - margin.top - margin.bottom
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
const x = d3.scaleBand().domain(items.map(([k]) => k)).range([0, w]).padding(0.1)
const y = d3.scaleLinear().domain([-4, 4]).range([h, 0])
const color = d3.scaleDiverging(d3.interpolateRdBu).domain([-4, 0, 4])
g.selectAll('rect').data(items).enter().append('rect')
.attr('x', d => x(d[0]))
.attr('y', d => y(Math.max(0, d[1].mean_delta || 0)))
.attr('width', x.bandwidth())
.attr('height', d => Math.abs(y(d[1].mean_delta || 0) - y(0)))
.attr('fill', d => color(d[1].mean_delta || 0))
g.append('g').attr('transform', `translate(0,${y(0)})`)
.call(d3.axisBottom(x)).selectAll('text')
.attr('transform', 'rotate(-40)').attr('text-anchor', 'end')
g.append('g').call(d3.axisLeft(y))
}
</script> </script>
<style scoped>
.panel { padding: .5rem; }
</style>

View File

@ -1,4 +1,66 @@
<template><div class="panel">Scenarios: results will appear here.</div></template> <template>
<div class="panel">
<h3>Scenarios: desirability × plausibility</h3>
<div v-if="loading">Loading</div>
<div v-else-if="error">{{ error }}</div>
<svg v-else ref="chart" :width="width" :height="height"></svg>
</div>
</template>
<script setup> <script setup>
defineProps({ simId: String, status: Object }) import { onMounted, ref, watch } from 'vue'
import * as d3 from 'd3'
import { getResults } from '../../api/interview'
const props = defineProps({ simId: String, status: Object })
const chart = ref(null); const loading = ref(true); const error = ref(null)
const width = 520, height = 520
watch(() => props.status?.status, (s) => { if (s === 'completed') load() })
onMounted(load)
async function load() {
loading.value = true; error.value = null
try {
// service interceptor returns the envelope {success, data, error} directly
const r = await getResults(props.simId, 'scenario')
if (!r.success) { error.value = r.error; return }
draw(r.data.aggregate.polarity || {})
} catch (e) { error.value = String(e) } finally { loading.value = false }
}
function draw(polarity) {
const pts = Object.entries(polarity)
.filter(([, v]) => v && v.n > 0)
.map(([sid, v]) => ({
sid, x: v.mean_plausibility, y: v.mean_desirability,
n: v.n, sdx: v.sd_plausibility, sdy: v.sd_desirability,
}))
if (!pts.length) return
const svg = d3.select(chart.value); svg.selectAll('*').remove()
const margin = { top: 20, right: 20, bottom: 40, left: 40 }
const w = width - margin.left - margin.right
const h = height - margin.top - margin.bottom
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
const x = d3.scaleLinear().domain([1, 7]).range([0, w])
const y = d3.scaleLinear().domain([1, 7]).range([h, 0])
g.append('line').attr('x1', 0).attr('x2', w).attr('y1', y(4)).attr('y2', y(4)).attr('stroke', '#ccc')
g.append('line').attr('x1', x(4)).attr('x2', x(4)).attr('y1', 0).attr('y2', h).attr('stroke', '#ccc')
g.selectAll('circle').data(pts).enter().append('circle')
.attr('cx', d => x(d.x)).attr('cy', d => y(d.y))
.attr('r', d => 6 + Math.sqrt(d.n))
.attr('fill', d3.schemeCategory10[1]).attr('opacity', .7)
g.selectAll('text.lbl').data(pts).enter().append('text')
.attr('class', 'lbl').attr('x', d => x(d.x) + 8).attr('y', d => y(d.y))
.text(d => `${d.sid} (n=${d.n})`)
g.append('g').attr('transform', `translate(0,${h})`).call(d3.axisBottom(x))
g.append('g').call(d3.axisLeft(y))
g.append('text').attr('x', w/2).attr('y', h+34).attr('text-anchor', 'middle').text('plausibility')
g.append('text').attr('transform', `rotate(-90)`).attr('x', -h/2).attr('y', -28)
.attr('text-anchor', 'middle').text('desirability')
}
</script> </script>
<style scoped>
.panel { padding: .5rem; }
</style>

View File

@ -1,4 +1,34 @@
<template><div class="panel">Synthesis: results will appear here.</div></template> <template>
<div class="panel">
<h3>Synthesis</h3>
<div v-if="loading">Loading</div>
<div v-else-if="error">{{ error }}</div>
<pre v-else class="report">{{ report }}</pre>
</div>
</template>
<script setup> <script setup>
defineProps({ simId: String, status: Object }) import { onMounted, ref, watch } from 'vue'
import { getSynthesis } from '../../api/interview'
const props = defineProps({ simId: String, status: Object })
const loading = ref(true); const error = ref(null); const report = ref('')
watch(() => props.status?.status, (s) => { if (s === 'completed') load() })
onMounted(load)
async function load() {
loading.value = true; error.value = null
try {
// service interceptor returns the envelope {success, data, error} directly
const r = await getSynthesis(props.simId)
if (!r.success) { error.value = r.error; return }
report.value = r.data.report_markdown
} catch (e) { error.value = String(e) } finally { loading.value = false }
}
</script> </script>
<style scoped>
.panel { padding: .5rem; }
.report { white-space: pre-wrap; font-family: ui-monospace, monospace; line-height: 1.4; }
</style>