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:
parent
fede66cac3
commit
acaa06170e
|
|
@ -1,4 +1,58 @@
|
||||||
<template><div class="panel">Delphi: results will appear here.</div></template>
|
<template>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Delphi convergence (R1→R3)</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue