Andrej Janchevski commited on
Commit ·
cd5136e
1
Parent(s): f31c85c
feat(coins): add interactive query builder and reasoning dashboard
Browse filesFull COINs demo at /demos/coins with query structure picker (7 templates),
searchable entity/relation dropdowns with server-side pagination, algorithm
selector filtered by structure+dataset compatibility, SVG query graph
visualization with slot-based editing, and a results dashboard showing
ranked predictions, community rank callout, and timing breakdown with
speedup estimate.
- Pinia store manages the full query lifecycle (registry load, slot
filling, predict call, error handling for 429/422/503)
- QueryGraph uses CSS Grid with ResizeObserver-driven edge routing
- SearchablePicker debounces 250ms with AbortController cancellation
- Route /demos/coins lazy-loaded, nav link + DemoPreviewCard wired
- src/frontend/src/api/coins.js +38 -2
- src/frontend/src/components/coins/AlgorithmSelector.vue +45 -0
- src/frontend/src/components/coins/CommunityRankCallout.vue +49 -0
- src/frontend/src/components/coins/PredictionList.vue +70 -0
- src/frontend/src/components/coins/QueryDescription.vue +31 -0
- src/frontend/src/components/coins/QueryGraph.vue +428 -0
- src/frontend/src/components/coins/QueryStructurePicker.vue +184 -0
- src/frontend/src/components/coins/ResultsDashboard.vue +44 -0
- src/frontend/src/components/coins/SearchableEntityDropdown.vue +28 -0
- src/frontend/src/components/coins/SearchablePicker.vue +282 -0
- src/frontend/src/components/coins/SearchableRelationDropdown.vue +28 -0
- src/frontend/src/components/coins/TimingPanel.vue +109 -0
- src/frontend/src/components/home/DemoPreviewCard.vue +10 -3
- src/frontend/src/components/layout/NavBar.vue +2 -0
- src/frontend/src/router/index.js +6 -0
- src/frontend/src/stores/coinsDemo.js +198 -0
- src/frontend/src/views/HomeView.vue +1 -0
- src/frontend/src/views/demos/CoinsView.vue +358 -0
src/frontend/src/api/coins.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
| 1 |
import { client } from './client'
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
export async function sampleTriples(datasetId, count = 1, seed = null) {
|
| 4 |
const params = { count }
|
| 5 |
if (seed != null) params.seed = seed
|
|
@@ -7,7 +26,24 @@ export async function sampleTriples(datasetId, count = 1, seed = null) {
|
|
| 7 |
return res.data
|
| 8 |
}
|
| 9 |
|
| 10 |
-
export async function
|
| 11 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
return res.data
|
| 13 |
}
|
|
|
|
| 1 |
import { client } from './client'
|
| 2 |
|
| 3 |
+
export async function listDatasets() {
|
| 4 |
+
const res = await client.get('/coins/datasets')
|
| 5 |
+
return res.data
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export async function searchEntities(datasetId, { q = '', page = 1, pageSize = 50, signal } = {}) {
|
| 9 |
+
const params = { page, page_size: pageSize }
|
| 10 |
+
if (q) params.q = q
|
| 11 |
+
const res = await client.get(`/coins/datasets/${datasetId}/entities`, { params, signal })
|
| 12 |
+
return res.data
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export async function searchRelations(datasetId, { q = '', page = 1, pageSize = 50, signal } = {}) {
|
| 16 |
+
const params = { page, page_size: pageSize }
|
| 17 |
+
if (q) params.q = q
|
| 18 |
+
const res = await client.get(`/coins/datasets/${datasetId}/relations`, { params, signal })
|
| 19 |
+
return res.data
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
export async function sampleTriples(datasetId, count = 1, seed = null) {
|
| 23 |
const params = { count }
|
| 24 |
if (seed != null) params.seed = seed
|
|
|
|
| 26 |
return res.data
|
| 27 |
}
|
| 28 |
|
| 29 |
+
export async function sampleQuery(datasetId, queryStructure, count = 1, seed = null) {
|
| 30 |
+
const params = { query_structure: queryStructure, count }
|
| 31 |
+
if (seed != null) params.seed = seed
|
| 32 |
+
const res = await client.get(`/coins/datasets/${datasetId}/sample-query`, { params })
|
| 33 |
+
return res.data
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export async function listModels() {
|
| 37 |
+
const res = await client.get('/coins/models')
|
| 38 |
+
return res.data
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export async function listQueryStructures() {
|
| 42 |
+
const res = await client.get('/coins/query-structures')
|
| 43 |
+
return res.data
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export async function predict(payload, { signal } = {}) {
|
| 47 |
+
const res = await client.post('/coins/predict', payload, { signal, timeout: 60000 })
|
| 48 |
return res.data
|
| 49 |
}
|
src/frontend/src/components/coins/AlgorithmSelector.vue
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
defineProps({
|
| 3 |
+
algorithms: { type: Array, required: true },
|
| 4 |
+
modelValue: { type: String, default: '' },
|
| 5 |
+
disabled: { type: Boolean, default: false },
|
| 6 |
+
})
|
| 7 |
+
const emit = defineEmits(['update:modelValue'])
|
| 8 |
+
function change(e) { emit('update:modelValue', e.target.value) }
|
| 9 |
+
</script>
|
| 10 |
+
|
| 11 |
+
<template>
|
| 12 |
+
<div class="algo-selector">
|
| 13 |
+
<label class="algo-label">Algorithm</label>
|
| 14 |
+
<select :value="modelValue" @change="change" class="algo-select" :disabled="disabled || !algorithms.length">
|
| 15 |
+
<option v-if="!algorithms.length" value="">No algorithm for this pair</option>
|
| 16 |
+
<option v-for="m in algorithms" :key="m.algorithm" :value="m.algorithm" :title="m.description">
|
| 17 |
+
{{ m.name }}
|
| 18 |
+
</option>
|
| 19 |
+
</select>
|
| 20 |
+
</div>
|
| 21 |
+
</template>
|
| 22 |
+
|
| 23 |
+
<style scoped>
|
| 24 |
+
.algo-selector { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; width: 100%; }
|
| 25 |
+
.algo-label {
|
| 26 |
+
font-size: 0.8em;
|
| 27 |
+
color: var(--text-muted);
|
| 28 |
+
text-transform: uppercase;
|
| 29 |
+
letter-spacing: 0.04em;
|
| 30 |
+
line-height: 1.2;
|
| 31 |
+
min-height: 1.2em;
|
| 32 |
+
}
|
| 33 |
+
.algo-select {
|
| 34 |
+
height: 2.4rem;
|
| 35 |
+
padding: 0 0.6rem;
|
| 36 |
+
border: 1px solid var(--border);
|
| 37 |
+
border-radius: 0.4rem;
|
| 38 |
+
background: var(--surface);
|
| 39 |
+
color: var(--text);
|
| 40 |
+
font: inherit;
|
| 41 |
+
box-sizing: border-box;
|
| 42 |
+
width: 100%;
|
| 43 |
+
}
|
| 44 |
+
.algo-select:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
|
| 45 |
+
</style>
|
src/frontend/src/components/coins/CommunityRankCallout.vue
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
defineProps({
|
| 3 |
+
rankC: { type: Number, required: true },
|
| 4 |
+
})
|
| 5 |
+
</script>
|
| 6 |
+
|
| 7 |
+
<template>
|
| 8 |
+
<div class="community-callout" :class="{ miss: rankC === 0 }">
|
| 9 |
+
<div class="cc-icon"><i :class="['icon', rankC === 0 ? 'times circle' : 'map marker alternate']"></i></div>
|
| 10 |
+
<div class="cc-body">
|
| 11 |
+
<div class="cc-head">
|
| 12 |
+
<span v-if="rankC > 0">Community rank <strong>#{{ rankC }}</strong></span>
|
| 13 |
+
<span v-else>No valid answers found</span>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="cc-sub">
|
| 16 |
+
<template v-if="rankC === 1">
|
| 17 |
+
The best-scoring community contained a valid answer — COINs localized the search immediately.
|
| 18 |
+
</template>
|
| 19 |
+
<template v-else-if="rankC > 1">
|
| 20 |
+
COINs had to scan {{ rankC }} communities before hitting a valid answer. Step 2 still only
|
| 21 |
+
scored candidates inside those communities, not the whole graph.
|
| 22 |
+
</template>
|
| 23 |
+
<template v-else>
|
| 24 |
+
The KG contains no answer matching this query. Try a different anchor, relation, or pin
|
| 25 |
+
different variables.
|
| 26 |
+
</template>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</template>
|
| 31 |
+
|
| 32 |
+
<style scoped>
|
| 33 |
+
.community-callout {
|
| 34 |
+
display: flex;
|
| 35 |
+
gap: 0.75rem;
|
| 36 |
+
padding: 0.85rem 1rem;
|
| 37 |
+
background: var(--primary-soft);
|
| 38 |
+
border-radius: 0.5rem;
|
| 39 |
+
border-left: 3px solid var(--primary);
|
| 40 |
+
}
|
| 41 |
+
.community-callout.miss {
|
| 42 |
+
background: rgba(192, 57, 43, 0.08);
|
| 43 |
+
border-left-color: #c0392b;
|
| 44 |
+
}
|
| 45 |
+
.cc-icon i.icon { font-size: 1.8em; color: var(--primary); margin: 0; }
|
| 46 |
+
.community-callout.miss .cc-icon i.icon { color: #c0392b; }
|
| 47 |
+
.cc-head { font-size: 1.05em; font-weight: 600; margin-bottom: 0.2rem; }
|
| 48 |
+
.cc-sub { font-size: 0.9em; color: var(--text-muted); }
|
| 49 |
+
</style>
|
src/frontend/src/components/coins/PredictionList.vue
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
defineProps({
|
| 3 |
+
predictions: { type: Array, required: true },
|
| 4 |
+
})
|
| 5 |
+
|
| 6 |
+
function barWidth(score) {
|
| 7 |
+
const pct = Math.max(0, Math.min(1, score)) * 100
|
| 8 |
+
return pct.toFixed(1) + '%'
|
| 9 |
+
}
|
| 10 |
+
</script>
|
| 11 |
+
|
| 12 |
+
<template>
|
| 13 |
+
<div class="pred-list">
|
| 14 |
+
<h3 class="ui header"><i class="list ol icon"></i>Top-K Predictions</h3>
|
| 15 |
+
<div v-if="!predictions.length" class="muted">No predictions returned.</div>
|
| 16 |
+
<div v-else class="pred-rows">
|
| 17 |
+
<div v-for="p in predictions" :key="p.entity_id" class="pred-row">
|
| 18 |
+
<div class="pred-rank" :title="`intra-community rank ${p.intra_community_rank}`">
|
| 19 |
+
#{{ p.rank }}
|
| 20 |
+
</div>
|
| 21 |
+
<div class="pred-body">
|
| 22 |
+
<div class="pred-name" :title="`entity id ${p.entity_id}`">{{ p.entity_name }}</div>
|
| 23 |
+
<div class="pred-bar">
|
| 24 |
+
<div class="pred-bar-fill" :style="{ width: barWidth(p.score) }"></div>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="pred-meta">
|
| 27 |
+
<span class="pred-score">score {{ p.score.toFixed(4) }}</span>
|
| 28 |
+
<span class="muted">intra-community #{{ p.intra_community_rank }}</span>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</template>
|
| 35 |
+
|
| 36 |
+
<style scoped>
|
| 37 |
+
.pred-list { padding: 0; }
|
| 38 |
+
.pred-rows { display: flex; flex-direction: column; gap: 0.5rem; }
|
| 39 |
+
.pred-row {
|
| 40 |
+
display: grid;
|
| 41 |
+
grid-template-columns: 3.5rem 1fr;
|
| 42 |
+
gap: 0.75rem;
|
| 43 |
+
align-items: center;
|
| 44 |
+
padding: 0.6rem;
|
| 45 |
+
background: var(--surface);
|
| 46 |
+
border: 1px solid var(--border);
|
| 47 |
+
border-radius: 0.5rem;
|
| 48 |
+
}
|
| 49 |
+
.pred-rank {
|
| 50 |
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
| 51 |
+
font-weight: 700;
|
| 52 |
+
color: var(--primary-strong);
|
| 53 |
+
text-align: center;
|
| 54 |
+
font-size: 1.1em;
|
| 55 |
+
}
|
| 56 |
+
.pred-body { min-width: 0; }
|
| 57 |
+
.pred-name { font-weight: 600; word-break: break-word; }
|
| 58 |
+
.pred-bar {
|
| 59 |
+
width: 100%;
|
| 60 |
+
height: 6px;
|
| 61 |
+
background: var(--surface-muted);
|
| 62 |
+
border-radius: 3px;
|
| 63 |
+
overflow: hidden;
|
| 64 |
+
margin: 0.35rem 0 0.25rem;
|
| 65 |
+
}
|
| 66 |
+
.pred-bar-fill { height: 100%; background: var(--primary); }
|
| 67 |
+
.pred-meta { display: flex; gap: 0.75rem; font-size: 0.8em; }
|
| 68 |
+
.pred-score { color: var(--text); font-family: ui-monospace, SFMono-Regular, monospace; }
|
| 69 |
+
.muted { color: var(--text-muted); }
|
| 70 |
+
</style>
|
src/frontend/src/components/coins/QueryDescription.vue
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
defineProps({
|
| 3 |
+
description: { type: String, required: true },
|
| 4 |
+
structure: { type: String, default: '' },
|
| 5 |
+
algorithm: { type: String, default: '' },
|
| 6 |
+
datasetId: { type: String, default: '' },
|
| 7 |
+
})
|
| 8 |
+
</script>
|
| 9 |
+
|
| 10 |
+
<template>
|
| 11 |
+
<div class="query-desc">
|
| 12 |
+
<div class="qd-chips">
|
| 13 |
+
<span v-if="datasetId" class="ui tiny label"><i class="database icon"></i>{{ datasetId }}</span>
|
| 14 |
+
<span v-if="structure" class="ui tiny label">{{ structure }}</span>
|
| 15 |
+
<span v-if="algorithm" class="ui tiny green label">{{ algorithm }}</span>
|
| 16 |
+
</div>
|
| 17 |
+
<p class="qd-text">{{ description }}</p>
|
| 18 |
+
</div>
|
| 19 |
+
</template>
|
| 20 |
+
|
| 21 |
+
<style scoped>
|
| 22 |
+
.query-desc {
|
| 23 |
+
padding: 0.75rem 1rem;
|
| 24 |
+
background: var(--primary-soft);
|
| 25 |
+
border-left: 3px solid var(--primary);
|
| 26 |
+
border-radius: 0.4rem;
|
| 27 |
+
margin-bottom: 1rem;
|
| 28 |
+
}
|
| 29 |
+
.qd-chips { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 0.35rem; }
|
| 30 |
+
.qd-text { margin: 0; font-weight: 500; word-break: break-word; }
|
| 31 |
+
</style>
|
src/frontend/src/components/coins/QueryGraph.vue
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
| 3 |
+
|
| 4 |
+
const props = defineProps({
|
| 5 |
+
structure: { type: Object, required: true },
|
| 6 |
+
mode: { type: String, default: 'edit' },
|
| 7 |
+
cellMinWidth: { type: Number, default: 200 },
|
| 8 |
+
cellMinHeight: { type: Number, default: 110 },
|
| 9 |
+
colGap: { type: Number, default: 72 },
|
| 10 |
+
rowGap: { type: Number, default: 56 },
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
const TARGET_ID = 't'
|
| 14 |
+
|
| 15 |
+
const EDIT_LAYOUTS = {
|
| 16 |
+
'1p': {
|
| 17 |
+
cols: 2, rows: 1,
|
| 18 |
+
cells: [
|
| 19 |
+
{ id: 'a', col: 1, row: 1 },
|
| 20 |
+
{ id: 't', col: 2, row: 1 },
|
| 21 |
+
],
|
| 22 |
+
},
|
| 23 |
+
'2p': {
|
| 24 |
+
cols: 3, rows: 1,
|
| 25 |
+
cells: [
|
| 26 |
+
{ id: 'a', col: 1, row: 1 },
|
| 27 |
+
{ id: 'v1', col: 2, row: 1 },
|
| 28 |
+
{ id: 't', col: 3, row: 1 },
|
| 29 |
+
],
|
| 30 |
+
},
|
| 31 |
+
'3p': {
|
| 32 |
+
cols: 4, rows: 1,
|
| 33 |
+
cells: [
|
| 34 |
+
{ id: 'a', col: 1, row: 1 },
|
| 35 |
+
{ id: 'v1', col: 2, row: 1 },
|
| 36 |
+
{ id: 'v2', col: 3, row: 1 },
|
| 37 |
+
{ id: 't', col: 4, row: 1 },
|
| 38 |
+
],
|
| 39 |
+
},
|
| 40 |
+
'2i': {
|
| 41 |
+
cols: 2, rows: 2,
|
| 42 |
+
cells: [
|
| 43 |
+
{ id: 'a1', col: 1, row: 1 },
|
| 44 |
+
{ id: 'a2', col: 1, row: 2 },
|
| 45 |
+
{ id: 't', col: 2, row: 1, rowSpan: 2 },
|
| 46 |
+
],
|
| 47 |
+
},
|
| 48 |
+
'3i': {
|
| 49 |
+
cols: 2, rows: 3,
|
| 50 |
+
cells: [
|
| 51 |
+
{ id: 'a1', col: 1, row: 1 },
|
| 52 |
+
{ id: 'a2', col: 1, row: 2 },
|
| 53 |
+
{ id: 'a3', col: 1, row: 3 },
|
| 54 |
+
{ id: 't', col: 2, row: 1, rowSpan: 3 },
|
| 55 |
+
],
|
| 56 |
+
},
|
| 57 |
+
'ip': {
|
| 58 |
+
cols: 3, rows: 2,
|
| 59 |
+
cells: [
|
| 60 |
+
{ id: 'a1', col: 1, row: 1 },
|
| 61 |
+
{ id: 'a2', col: 1, row: 2 },
|
| 62 |
+
{ id: 'v1', col: 2, row: 1, rowSpan: 2 },
|
| 63 |
+
{ id: 't', col: 3, row: 1, rowSpan: 2 },
|
| 64 |
+
],
|
| 65 |
+
},
|
| 66 |
+
'pi': {
|
| 67 |
+
cols: 3, rows: 2,
|
| 68 |
+
cells: [
|
| 69 |
+
{ id: 'a1', col: 1, row: 1 },
|
| 70 |
+
{ id: 'v1', col: 2, row: 1 },
|
| 71 |
+
{ id: 'a2', col: 1, row: 2 },
|
| 72 |
+
{ id: 't', col: 3, row: 1, rowSpan: 2 },
|
| 73 |
+
],
|
| 74 |
+
},
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Preview reuses the 2D edit layouts: multi-row intersection queries (2i/3i/ip/pi)
|
| 78 |
+
// route converging edges as straight diagonals instead of stacked arcs, so relation
|
| 79 |
+
// labels don't pile on top of each other and the grid's bounding box already
|
| 80 |
+
// contains every edge — no need to expand the SVG viewBox beyond it.
|
| 81 |
+
const PREVIEW_LAYOUTS = EDIT_LAYOUTS
|
| 82 |
+
|
| 83 |
+
const allNodes = computed(() => {
|
| 84 |
+
const base = props.structure.nodes.slice()
|
| 85 |
+
if (!base.some((n) => n.id === TARGET_ID)) {
|
| 86 |
+
base.push({ id: TARGET_ID, type: 'target', label: '?' })
|
| 87 |
+
}
|
| 88 |
+
return base
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
const nodeById = computed(() => {
|
| 92 |
+
const m = {}
|
| 93 |
+
for (const n of allNodes.value) m[n.id] = n
|
| 94 |
+
return m
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
const layout = computed(() => {
|
| 98 |
+
const map = props.mode === 'preview' ? PREVIEW_LAYOUTS : EDIT_LAYOUTS
|
| 99 |
+
return map[props.structure.id] || { cols: 1, rows: 1, cells: [] }
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
const cellCol = computed(() => {
|
| 103 |
+
const m = {}
|
| 104 |
+
for (const c of layout.value.cells) m[c.id] = c.col
|
| 105 |
+
return m
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
const dynamicColGap = ref(props.colGap)
|
| 109 |
+
const dynamicRowGap = ref(props.rowGap)
|
| 110 |
+
|
| 111 |
+
const gridStyle = computed(() => ({
|
| 112 |
+
'grid-template-columns': `repeat(${layout.value.cols}, minmax(min-content, 1fr))`,
|
| 113 |
+
'grid-template-rows': `repeat(${layout.value.rows}, minmax(${props.cellMinHeight}px, auto))`,
|
| 114 |
+
'column-gap': dynamicColGap.value + 'px',
|
| 115 |
+
'row-gap': dynamicRowGap.value + 'px',
|
| 116 |
+
}))
|
| 117 |
+
|
| 118 |
+
function cellStyle(cell) {
|
| 119 |
+
const colEnd = cell.colSpan ? cell.col + cell.colSpan : cell.col + 1
|
| 120 |
+
const rowEnd = cell.rowSpan ? cell.row + cell.rowSpan : cell.row + 1
|
| 121 |
+
return {
|
| 122 |
+
'grid-column': `${cell.col} / ${colEnd}`,
|
| 123 |
+
'grid-row': `${cell.row} / ${rowEnd}`,
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const gridEl = ref(null)
|
| 128 |
+
const nodeRefs = reactive({})
|
| 129 |
+
function setNodeRef(id, el) {
|
| 130 |
+
if (el) nodeRefs[id] = el
|
| 131 |
+
else delete nodeRefs[id]
|
| 132 |
+
}
|
| 133 |
+
const edgeRefs = reactive({})
|
| 134 |
+
function setEdgeRef(id, el) {
|
| 135 |
+
if (el) edgeRefs[id] = el
|
| 136 |
+
else delete edgeRefs[id]
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const geometry = ref({ w: 0, h: 0, centers: {}, rects: {} })
|
| 140 |
+
|
| 141 |
+
// The grid cell is much larger than its content pill (minmax(Npx, auto)),
|
| 142 |
+
// so measuring the cell gives the line endpoints a rect that overshoots the
|
| 143 |
+
// visible pill. Measure the cell's first child (the slot-node or fallback pill)
|
| 144 |
+
// instead — it hugs the pill, so intersectRect lands right at the pill edge.
|
| 145 |
+
function contentOf(cellEl) {
|
| 146 |
+
return cellEl?.firstElementChild || cellEl
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function measure() {
|
| 150 |
+
if (!gridEl.value) return
|
| 151 |
+
let maxNodeW = 0
|
| 152 |
+
let maxNodeH = 0
|
| 153 |
+
for (const id of Object.keys(nodeRefs)) {
|
| 154 |
+
const el = contentOf(nodeRefs[id])
|
| 155 |
+
if (!el) continue
|
| 156 |
+
const r = el.getBoundingClientRect()
|
| 157 |
+
if (r.width > maxNodeW) maxNodeW = r.width
|
| 158 |
+
if (r.height > maxNodeH) maxNodeH = r.height
|
| 159 |
+
}
|
| 160 |
+
let maxEdgeW = 0
|
| 161 |
+
let maxEdgeH = 0
|
| 162 |
+
for (const id of Object.keys(edgeRefs)) {
|
| 163 |
+
const el = edgeRefs[id]
|
| 164 |
+
if (!el) continue
|
| 165 |
+
const r = el.getBoundingClientRect()
|
| 166 |
+
if (r.width > maxEdgeW) maxEdgeW = r.width
|
| 167 |
+
if (r.height > maxEdgeH) maxEdgeH = r.height
|
| 168 |
+
}
|
| 169 |
+
const colGapFromEdge = maxEdgeW + 48
|
| 170 |
+
const colGapFromNodeOverlap = Math.ceil(maxNodeW * 0.15) + 24
|
| 171 |
+
let gap = Math.max(props.colGap, colGapFromEdge, colGapFromNodeOverlap)
|
| 172 |
+
const numGaps = Math.max(1, layout.value.cols - 1)
|
| 173 |
+
const containerW = gridEl.value.parentElement?.clientWidth || gridEl.value.clientWidth
|
| 174 |
+
const maxGap = Math.floor((containerW - layout.value.cols * 80) / numGaps)
|
| 175 |
+
if (maxGap > 0 && gap > maxGap) gap = Math.max(props.colGap, maxGap)
|
| 176 |
+
dynamicColGap.value = gap
|
| 177 |
+
dynamicRowGap.value = Math.max(props.rowGap, maxEdgeH + 28)
|
| 178 |
+
|
| 179 |
+
const gridRect = gridEl.value.getBoundingClientRect()
|
| 180 |
+
const centers = {}
|
| 181 |
+
const rects = {}
|
| 182 |
+
for (const id of Object.keys(nodeRefs)) {
|
| 183 |
+
const el = contentOf(nodeRefs[id])
|
| 184 |
+
if (!el) continue
|
| 185 |
+
const r = el.getBoundingClientRect()
|
| 186 |
+
const x = r.left - gridRect.left + r.width / 2
|
| 187 |
+
const y = r.top - gridRect.top + r.height / 2
|
| 188 |
+
centers[id] = { x, y }
|
| 189 |
+
rects[id] = { w: r.width, h: r.height }
|
| 190 |
+
}
|
| 191 |
+
geometry.value = { w: gridRect.width, h: gridRect.height, centers, rects }
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function intersectRect(cx, cy, w, h, tx, ty) {
|
| 195 |
+
const dx = tx - cx
|
| 196 |
+
const dy = ty - cy
|
| 197 |
+
if (dx === 0 && dy === 0) return { x: cx, y: cy }
|
| 198 |
+
const hw = w / 2
|
| 199 |
+
const hh = h / 2
|
| 200 |
+
const sx = dx === 0 ? Infinity : Math.abs(hw / dx)
|
| 201 |
+
const sy = dy === 0 ? Infinity : Math.abs(hh / dy)
|
| 202 |
+
const t = Math.min(sx, sy)
|
| 203 |
+
return { x: cx + dx * t, y: cy + dy * t }
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const edgeRoutes = computed(() => {
|
| 207 |
+
const { centers, rects } = geometry.value
|
| 208 |
+
const cols = cellCol.value
|
| 209 |
+
return (props.structure.edges || []).map((e) => {
|
| 210 |
+
const s = centers[e.source]
|
| 211 |
+
const t = centers[e.target]
|
| 212 |
+
if (!s || !t) return null
|
| 213 |
+
const sr = rects[e.source] || { w: 0, h: 0 }
|
| 214 |
+
const tr = rects[e.target] || { w: 0, h: 0 }
|
| 215 |
+
const p1 = intersectRect(s.x, s.y, sr.w - 4, sr.h - 4, t.x, t.y)
|
| 216 |
+
const p2 = intersectRect(t.x, t.y, tr.w - 4, tr.h - 4, s.x, s.y)
|
| 217 |
+
|
| 218 |
+
const colSpan = Math.abs((cols[e.target] || 0) - (cols[e.source] || 0))
|
| 219 |
+
const sameRow = Math.abs(s.y - t.y) < 8
|
| 220 |
+
const needsCurve = colSpan > 1 && sameRow
|
| 221 |
+
let path
|
| 222 |
+
let mx = (p1.x + p2.x) / 2
|
| 223 |
+
let my = (p1.y + p2.y) / 2
|
| 224 |
+
if (needsCurve) {
|
| 225 |
+
const arc = Math.min(70, 22 + colSpan * 14)
|
| 226 |
+
const cy = my - arc
|
| 227 |
+
path = `M ${p1.x} ${p1.y} Q ${mx} ${cy} ${p2.x} ${p2.y}`
|
| 228 |
+
my = my - arc / 2
|
| 229 |
+
} else {
|
| 230 |
+
path = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`
|
| 231 |
+
}
|
| 232 |
+
return { ...e, path, mx, my }
|
| 233 |
+
}).filter(Boolean)
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
let ro = null
|
| 237 |
+
let ticking = false
|
| 238 |
+
function schedule() {
|
| 239 |
+
if (ticking) return
|
| 240 |
+
ticking = true
|
| 241 |
+
requestAnimationFrame(() => {
|
| 242 |
+
ticking = false
|
| 243 |
+
measure()
|
| 244 |
+
})
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
onMounted(async () => {
|
| 248 |
+
await nextTick()
|
| 249 |
+
measure()
|
| 250 |
+
ro = new ResizeObserver(schedule)
|
| 251 |
+
if (gridEl.value) ro.observe(gridEl.value)
|
| 252 |
+
for (const id of Object.keys(nodeRefs)) {
|
| 253 |
+
if (nodeRefs[id]) ro.observe(nodeRefs[id])
|
| 254 |
+
}
|
| 255 |
+
for (const id of Object.keys(edgeRefs)) {
|
| 256 |
+
if (edgeRefs[id]) ro.observe(edgeRefs[id])
|
| 257 |
+
}
|
| 258 |
+
window.addEventListener('resize', schedule)
|
| 259 |
+
if (document.fonts?.ready) document.fonts.ready.then(schedule)
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
onBeforeUnmount(() => {
|
| 263 |
+
if (ro) ro.disconnect()
|
| 264 |
+
window.removeEventListener('resize', schedule)
|
| 265 |
+
})
|
| 266 |
+
|
| 267 |
+
watch(() => props.structure?.id, async () => {
|
| 268 |
+
await nextTick()
|
| 269 |
+
schedule()
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
watch(nodeRefs, () => {
|
| 273 |
+
if (!ro) return
|
| 274 |
+
for (const id of Object.keys(nodeRefs)) {
|
| 275 |
+
const el = nodeRefs[id]
|
| 276 |
+
if (el) ro.observe(el)
|
| 277 |
+
}
|
| 278 |
+
schedule()
|
| 279 |
+
}, { deep: true })
|
| 280 |
+
|
| 281 |
+
watch(edgeRefs, () => {
|
| 282 |
+
if (!ro) return
|
| 283 |
+
for (const id of Object.keys(edgeRefs)) {
|
| 284 |
+
const el = edgeRefs[id]
|
| 285 |
+
if (el) ro.observe(el)
|
| 286 |
+
}
|
| 287 |
+
schedule()
|
| 288 |
+
}, { deep: true })
|
| 289 |
+
|
| 290 |
+
function edgeLabelStyle(e) {
|
| 291 |
+
return { left: e.mx + 'px', top: e.my + 'px' }
|
| 292 |
+
}
|
| 293 |
+
</script>
|
| 294 |
+
|
| 295 |
+
<template>
|
| 296 |
+
<div class="query-graph" :class="{ preview: mode === 'preview' }">
|
| 297 |
+
<div class="qg-grid" ref="gridEl" :style="gridStyle">
|
| 298 |
+
<svg class="qg-edges"
|
| 299 |
+
:viewBox="`0 0 ${geometry.w || 1} ${geometry.h || 1}`"
|
| 300 |
+
:width="geometry.w || 0" :height="geometry.h || 0"
|
| 301 |
+
aria-hidden="true"
|
| 302 |
+
preserveAspectRatio="none">
|
| 303 |
+
<defs>
|
| 304 |
+
<marker id="qg-arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
| 305 |
+
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
| 306 |
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--primary)" />
|
| 307 |
+
</marker>
|
| 308 |
+
</defs>
|
| 309 |
+
<path v-for="e in edgeRoutes" :key="'e-' + e.id"
|
| 310 |
+
:d="e.path"
|
| 311 |
+
class="qg-edge" marker-end="url(#qg-arrow)" />
|
| 312 |
+
</svg>
|
| 313 |
+
|
| 314 |
+
<div v-for="cell in layout.cells" :key="'c-' + cell.id"
|
| 315 |
+
:ref="(el) => setNodeRef(cell.id, el)"
|
| 316 |
+
class="qg-cell"
|
| 317 |
+
:class="['qg-' + (nodeById[cell.id]?.type || 'target')]"
|
| 318 |
+
:style="cellStyle(cell)">
|
| 319 |
+
<slot :name="'node-' + cell.id" :node="nodeById[cell.id]">
|
| 320 |
+
<span class="qg-fallback" :class="'qg-fallback-' + (nodeById[cell.id]?.type || 'target')">
|
| 321 |
+
{{ nodeById[cell.id]?.label || cell.id }}
|
| 322 |
+
</span>
|
| 323 |
+
</slot>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
+
<div v-for="e in edgeRoutes" :key="'el-' + e.id"
|
| 327 |
+
:ref="(el) => setEdgeRef(e.id, el)"
|
| 328 |
+
class="qg-edge-label"
|
| 329 |
+
:style="edgeLabelStyle(e)">
|
| 330 |
+
<slot :name="'edge-' + e.id" :edge="e">
|
| 331 |
+
<span class="qg-fallback qg-fallback-edge">{{ e.label || e.id }}</span>
|
| 332 |
+
</slot>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
</template>
|
| 337 |
+
|
| 338 |
+
<style scoped>
|
| 339 |
+
.query-graph {
|
| 340 |
+
position: relative;
|
| 341 |
+
width: 100%;
|
| 342 |
+
display: flex;
|
| 343 |
+
justify-content: center;
|
| 344 |
+
padding: 1rem 0;
|
| 345 |
+
}
|
| 346 |
+
.qg-grid {
|
| 347 |
+
position: relative;
|
| 348 |
+
display: grid;
|
| 349 |
+
justify-items: center;
|
| 350 |
+
align-items: center;
|
| 351 |
+
width: 100%;
|
| 352 |
+
isolation: isolate;
|
| 353 |
+
}
|
| 354 |
+
.qg-cell {
|
| 355 |
+
position: relative;
|
| 356 |
+
z-index: 2;
|
| 357 |
+
display: inline-flex;
|
| 358 |
+
align-items: center;
|
| 359 |
+
justify-content: center;
|
| 360 |
+
}
|
| 361 |
+
.qg-cell:focus-within,
|
| 362 |
+
.qg-cell:hover {
|
| 363 |
+
z-index: 20;
|
| 364 |
+
}
|
| 365 |
+
.qg-edges {
|
| 366 |
+
position: absolute;
|
| 367 |
+
inset: 0;
|
| 368 |
+
pointer-events: none;
|
| 369 |
+
z-index: 1;
|
| 370 |
+
overflow: visible;
|
| 371 |
+
}
|
| 372 |
+
.qg-edge {
|
| 373 |
+
stroke: var(--primary);
|
| 374 |
+
stroke-width: 2;
|
| 375 |
+
fill: none;
|
| 376 |
+
opacity: 0.8;
|
| 377 |
+
}
|
| 378 |
+
.qg-edge-label {
|
| 379 |
+
position: absolute;
|
| 380 |
+
transform: translate(-50%, -50%);
|
| 381 |
+
z-index: 3;
|
| 382 |
+
background: var(--surface);
|
| 383 |
+
border-radius: 999px;
|
| 384 |
+
padding: 0.15rem 0.35rem;
|
| 385 |
+
}
|
| 386 |
+
.qg-edge-label:focus-within,
|
| 387 |
+
.qg-edge-label:hover {
|
| 388 |
+
z-index: 21;
|
| 389 |
+
}
|
| 390 |
+
.qg-fallback {
|
| 391 |
+
display: inline-block;
|
| 392 |
+
padding: 0.4em 0.8em;
|
| 393 |
+
border-radius: 999px;
|
| 394 |
+
background: var(--primary-soft);
|
| 395 |
+
color: var(--text);
|
| 396 |
+
border: 1px solid var(--primary);
|
| 397 |
+
font-size: 0.9em;
|
| 398 |
+
max-width: min(40ch, 90vw);
|
| 399 |
+
word-break: break-word;
|
| 400 |
+
}
|
| 401 |
+
.qg-fallback-target {
|
| 402 |
+
background: var(--surface);
|
| 403 |
+
border: 2px dashed var(--primary);
|
| 404 |
+
font-weight: 700;
|
| 405 |
+
}
|
| 406 |
+
.qg-fallback-variable {
|
| 407 |
+
background: var(--surface-muted);
|
| 408 |
+
border-style: dashed;
|
| 409 |
+
}
|
| 410 |
+
.qg-fallback-edge {
|
| 411 |
+
background: var(--surface);
|
| 412 |
+
border: 1px solid var(--border);
|
| 413 |
+
color: var(--text-muted);
|
| 414 |
+
font-size: 0.8em;
|
| 415 |
+
padding: 0.2em 0.6em;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.query-graph.preview { padding: 0; }
|
| 419 |
+
.query-graph.preview .qg-edge { stroke-width: 1.5; }
|
| 420 |
+
.query-graph.preview .qg-fallback {
|
| 421 |
+
padding: 0.25em 0.55em;
|
| 422 |
+
font-size: 0.7em;
|
| 423 |
+
max-width: 10ch;
|
| 424 |
+
white-space: nowrap;
|
| 425 |
+
overflow: hidden;
|
| 426 |
+
text-overflow: ellipsis;
|
| 427 |
+
}
|
| 428 |
+
</style>
|
src/frontend/src/components/coins/QueryStructurePicker.vue
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import { ref, computed, watch } from 'vue'
|
| 3 |
+
import QueryGraph from './QueryGraph.vue'
|
| 4 |
+
|
| 5 |
+
const props = defineProps({
|
| 6 |
+
structures: { type: Array, required: true },
|
| 7 |
+
modelValue: { type: String, default: '' },
|
| 8 |
+
})
|
| 9 |
+
const emit = defineEmits(['update:modelValue'])
|
| 10 |
+
|
| 11 |
+
const index = ref(0)
|
| 12 |
+
|
| 13 |
+
watch(
|
| 14 |
+
() => [props.modelValue, props.structures.length],
|
| 15 |
+
() => {
|
| 16 |
+
const i = props.structures.findIndex((s) => s.id === props.modelValue)
|
| 17 |
+
if (i >= 0) index.value = i
|
| 18 |
+
},
|
| 19 |
+
{ immediate: true },
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
const current = computed(() => props.structures[index.value] || null)
|
| 23 |
+
const count = computed(() => props.structures.length)
|
| 24 |
+
|
| 25 |
+
function go(delta) {
|
| 26 |
+
if (!count.value) return
|
| 27 |
+
const next = (index.value + delta + count.value) % count.value
|
| 28 |
+
index.value = next
|
| 29 |
+
const s = props.structures[next]
|
| 30 |
+
if (s) emit('update:modelValue', s.id)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function jump(i) {
|
| 34 |
+
if (i < 0 || i >= count.value) return
|
| 35 |
+
index.value = i
|
| 36 |
+
emit('update:modelValue', props.structures[i].id)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function select() {
|
| 40 |
+
if (current.value) emit('update:modelValue', current.value.id)
|
| 41 |
+
}
|
| 42 |
+
</script>
|
| 43 |
+
|
| 44 |
+
<template>
|
| 45 |
+
<div class="structure-carousel" v-if="current">
|
| 46 |
+
<button type="button" class="nav-btn" @click="go(-1)" aria-label="Previous structure"
|
| 47 |
+
:disabled="count < 2">
|
| 48 |
+
<i class="chevron left icon"></i>
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
<button type="button" class="structure-card"
|
| 52 |
+
:class="{ active: modelValue === current.id }"
|
| 53 |
+
@click="select" :aria-pressed="modelValue === current.id">
|
| 54 |
+
<div class="structure-head">
|
| 55 |
+
<span class="structure-id">{{ current.id }}</span>
|
| 56 |
+
<span class="structure-name">{{ current.name }}</span>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="structure-viz">
|
| 59 |
+
<QueryGraph :structure="current" mode="preview"
|
| 60 |
+
:cell-min-width="56" :cell-min-height="32"
|
| 61 |
+
:col-gap="72" :row-gap="28" />
|
| 62 |
+
</div>
|
| 63 |
+
<div class="structure-desc">{{ current.description }}</div>
|
| 64 |
+
</button>
|
| 65 |
+
|
| 66 |
+
<button type="button" class="nav-btn" @click="go(1)" aria-label="Next structure"
|
| 67 |
+
:disabled="count < 2">
|
| 68 |
+
<i class="chevron right icon"></i>
|
| 69 |
+
</button>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div v-if="count > 1" class="dots">
|
| 73 |
+
<button v-for="(s, i) in structures" :key="s.id" type="button"
|
| 74 |
+
class="dot" :class="{ active: i === index }"
|
| 75 |
+
@click="jump(i)" :aria-label="`Jump to ${s.id}`">
|
| 76 |
+
<span>{{ s.id }}</span>
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
</template>
|
| 80 |
+
|
| 81 |
+
<style scoped>
|
| 82 |
+
.structure-carousel {
|
| 83 |
+
display: grid;
|
| 84 |
+
grid-template-columns: auto 1fr auto;
|
| 85 |
+
align-items: stretch;
|
| 86 |
+
gap: 0.5rem;
|
| 87 |
+
}
|
| 88 |
+
.nav-btn {
|
| 89 |
+
background: var(--surface);
|
| 90 |
+
border: 1px solid var(--border);
|
| 91 |
+
border-radius: 0.5rem;
|
| 92 |
+
color: var(--primary-strong);
|
| 93 |
+
cursor: pointer;
|
| 94 |
+
padding: 0 0.6rem;
|
| 95 |
+
font-size: 1.3rem;
|
| 96 |
+
transition: 0.15s;
|
| 97 |
+
}
|
| 98 |
+
.nav-btn:hover:enabled { background: var(--primary-soft); border-color: var(--primary); }
|
| 99 |
+
.nav-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 100 |
+
.nav-btn i.icon { margin: 0; }
|
| 101 |
+
|
| 102 |
+
.structure-card {
|
| 103 |
+
background: var(--surface);
|
| 104 |
+
border: 1px solid var(--border);
|
| 105 |
+
border-radius: 0.6rem;
|
| 106 |
+
padding: 0.5rem 0.5rem 0.6rem;
|
| 107 |
+
cursor: pointer;
|
| 108 |
+
text-align: center;
|
| 109 |
+
color: var(--text);
|
| 110 |
+
font: inherit;
|
| 111 |
+
transition: 0.15s;
|
| 112 |
+
box-shadow: var(--shadow-sm);
|
| 113 |
+
display: flex;
|
| 114 |
+
flex-direction: column;
|
| 115 |
+
align-items: center;
|
| 116 |
+
gap: 0.35rem;
|
| 117 |
+
min-height: 300px;
|
| 118 |
+
overflow: visible;
|
| 119 |
+
}
|
| 120 |
+
.structure-card:hover { border-color: var(--primary); transform: translateY(-1px); }
|
| 121 |
+
.structure-card.active {
|
| 122 |
+
border-color: var(--primary);
|
| 123 |
+
background: var(--primary-soft);
|
| 124 |
+
box-shadow: var(--shadow-md);
|
| 125 |
+
}
|
| 126 |
+
.structure-head {
|
| 127 |
+
display: flex;
|
| 128 |
+
align-items: baseline;
|
| 129 |
+
justify-content: center;
|
| 130 |
+
gap: 0.5rem;
|
| 131 |
+
flex-wrap: wrap;
|
| 132 |
+
}
|
| 133 |
+
.structure-id {
|
| 134 |
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
| 135 |
+
font-weight: 700;
|
| 136 |
+
color: var(--primary-strong);
|
| 137 |
+
font-size: 1.2em;
|
| 138 |
+
}
|
| 139 |
+
.structure-name { font-weight: 600; font-size: 1em; }
|
| 140 |
+
.structure-viz {
|
| 141 |
+
min-height: 180px;
|
| 142 |
+
width: 100%;
|
| 143 |
+
display: flex;
|
| 144 |
+
align-items: center;
|
| 145 |
+
justify-content: center;
|
| 146 |
+
overflow: visible;
|
| 147 |
+
}
|
| 148 |
+
.structure-desc {
|
| 149 |
+
font-size: 0.85em;
|
| 150 |
+
color: var(--text-muted);
|
| 151 |
+
line-height: 1.4;
|
| 152 |
+
max-width: 48rem;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.dots {
|
| 156 |
+
display: flex;
|
| 157 |
+
gap: 0.35rem;
|
| 158 |
+
justify-content: center;
|
| 159 |
+
margin-top: 0.75rem;
|
| 160 |
+
flex-wrap: wrap;
|
| 161 |
+
}
|
| 162 |
+
.dot {
|
| 163 |
+
min-width: 2.6rem;
|
| 164 |
+
height: 1.8rem;
|
| 165 |
+
padding: 0 0.5rem;
|
| 166 |
+
border-radius: 999px;
|
| 167 |
+
border: 1px solid var(--border);
|
| 168 |
+
background: var(--surface);
|
| 169 |
+
color: var(--text-muted);
|
| 170 |
+
font: inherit;
|
| 171 |
+
font-size: 0.78em;
|
| 172 |
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
| 173 |
+
font-weight: 600;
|
| 174 |
+
cursor: pointer;
|
| 175 |
+
transition: 0.15s;
|
| 176 |
+
}
|
| 177 |
+
.dot:hover { border-color: var(--primary); color: var(--primary-strong); }
|
| 178 |
+
.dot.active {
|
| 179 |
+
background: var(--primary);
|
| 180 |
+
color: #fff;
|
| 181 |
+
border-color: var(--primary);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
</style>
|
src/frontend/src/components/coins/ResultsDashboard.vue
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import QueryDescription from './QueryDescription.vue'
|
| 3 |
+
import PredictionList from './PredictionList.vue'
|
| 4 |
+
import CommunityRankCallout from './CommunityRankCallout.vue'
|
| 5 |
+
import TimingPanel from './TimingPanel.vue'
|
| 6 |
+
|
| 7 |
+
defineProps({
|
| 8 |
+
result: { type: Object, required: true },
|
| 9 |
+
})
|
| 10 |
+
</script>
|
| 11 |
+
|
| 12 |
+
<template>
|
| 13 |
+
<div class="results-dashboard">
|
| 14 |
+
<QueryDescription
|
| 15 |
+
:description="result.query_description"
|
| 16 |
+
:structure="result.query_structure"
|
| 17 |
+
:algorithm="result.algorithm"
|
| 18 |
+
:dataset-id="result.dataset_id" />
|
| 19 |
+
|
| 20 |
+
<CommunityRankCallout :rank-c="result.timing?.rank_c ?? 0" />
|
| 21 |
+
|
| 22 |
+
<div class="dashboard-grid">
|
| 23 |
+
<div class="ui segment">
|
| 24 |
+
<PredictionList :predictions="result.predictions || []" />
|
| 25 |
+
</div>
|
| 26 |
+
<div class="ui segment">
|
| 27 |
+
<TimingPanel :timing="result.timing || {}" />
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</template>
|
| 32 |
+
|
| 33 |
+
<style scoped>
|
| 34 |
+
.results-dashboard { display: flex; flex-direction: column; gap: 1rem; }
|
| 35 |
+
.dashboard-grid {
|
| 36 |
+
display: grid;
|
| 37 |
+
grid-template-columns: 1fr 1fr;
|
| 38 |
+
gap: 1rem;
|
| 39 |
+
}
|
| 40 |
+
.ui.segment { margin: 0 !important; }
|
| 41 |
+
@media (max-width: 900px) {
|
| 42 |
+
.dashboard-grid { grid-template-columns: 1fr; }
|
| 43 |
+
}
|
| 44 |
+
</style>
|
src/frontend/src/components/coins/SearchableEntityDropdown.vue
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import { computed } from 'vue'
|
| 3 |
+
import { searchEntities } from '../../api/coins'
|
| 4 |
+
import SearchablePicker from './SearchablePicker.vue'
|
| 5 |
+
|
| 6 |
+
const props = defineProps({
|
| 7 |
+
modelValue: { type: Object, default: null },
|
| 8 |
+
datasetId: { type: String, required: true },
|
| 9 |
+
placeholder: { type: String, default: 'Pick entity' },
|
| 10 |
+
disabled: { type: Boolean, default: false },
|
| 11 |
+
})
|
| 12 |
+
defineEmits(['update:modelValue'])
|
| 13 |
+
|
| 14 |
+
const fetcher = computed(() => {
|
| 15 |
+
const ds = props.datasetId
|
| 16 |
+
return (opts) => searchEntities(ds, opts)
|
| 17 |
+
})
|
| 18 |
+
</script>
|
| 19 |
+
|
| 20 |
+
<template>
|
| 21 |
+
<SearchablePicker
|
| 22 |
+
:model-value="modelValue"
|
| 23 |
+
:fetcher="fetcher"
|
| 24 |
+
kind="entity"
|
| 25 |
+
:placeholder="placeholder"
|
| 26 |
+
:disabled="disabled || !datasetId"
|
| 27 |
+
@update:modelValue="$emit('update:modelValue', $event)" />
|
| 28 |
+
</template>
|
src/frontend/src/components/coins/SearchablePicker.vue
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import { ref, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
| 3 |
+
|
| 4 |
+
const props = defineProps({
|
| 5 |
+
modelValue: { type: Object, default: null },
|
| 6 |
+
fetcher: { type: Function, required: true },
|
| 7 |
+
placeholder: { type: String, default: 'Pick…' },
|
| 8 |
+
kind: { type: String, default: 'entity' },
|
| 9 |
+
disabled: { type: Boolean, default: false },
|
| 10 |
+
pageSize: { type: Number, default: 25 },
|
| 11 |
+
})
|
| 12 |
+
const emit = defineEmits(['update:modelValue'])
|
| 13 |
+
|
| 14 |
+
const open = ref(false)
|
| 15 |
+
const query = ref('')
|
| 16 |
+
const items = ref([])
|
| 17 |
+
const total = ref(0)
|
| 18 |
+
const page = ref(1)
|
| 19 |
+
const loading = ref(false)
|
| 20 |
+
const errorMsg = ref('')
|
| 21 |
+
const highlight = ref(-1)
|
| 22 |
+
const rootEl = ref(null)
|
| 23 |
+
const inputEl = ref(null)
|
| 24 |
+
|
| 25 |
+
let abortCtl = null
|
| 26 |
+
let debounceTimer = null
|
| 27 |
+
|
| 28 |
+
const display = computed(() => {
|
| 29 |
+
if (!props.modelValue) return ''
|
| 30 |
+
return props.modelValue.label || props.modelValue.name || String(props.modelValue.id)
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
const hasMore = computed(() => items.value.length < total.value)
|
| 34 |
+
|
| 35 |
+
async function runFetch({ reset = false } = {}) {
|
| 36 |
+
if (abortCtl) abortCtl.abort()
|
| 37 |
+
abortCtl = new AbortController()
|
| 38 |
+
loading.value = true
|
| 39 |
+
errorMsg.value = ''
|
| 40 |
+
try {
|
| 41 |
+
const res = await props.fetcher({
|
| 42 |
+
q: query.value,
|
| 43 |
+
page: reset ? 1 : page.value,
|
| 44 |
+
pageSize: props.pageSize,
|
| 45 |
+
signal: abortCtl.signal,
|
| 46 |
+
})
|
| 47 |
+
const list = res?.entities || res?.relations || res?.results || []
|
| 48 |
+
total.value = res?.total ?? list.length
|
| 49 |
+
if (reset) {
|
| 50 |
+
items.value = list
|
| 51 |
+
page.value = 1
|
| 52 |
+
highlight.value = list.length ? 0 : -1
|
| 53 |
+
} else {
|
| 54 |
+
items.value = [...items.value, ...list]
|
| 55 |
+
}
|
| 56 |
+
} catch (e) {
|
| 57 |
+
if (e?.name === 'CanceledError' || e?.name === 'AbortError') return
|
| 58 |
+
errorMsg.value = e?.response?.data?.error?.message || e?.message || 'Lookup failed'
|
| 59 |
+
} finally {
|
| 60 |
+
loading.value = false
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function scheduleFetch() {
|
| 65 |
+
if (debounceTimer) clearTimeout(debounceTimer)
|
| 66 |
+
debounceTimer = setTimeout(() => runFetch({ reset: true }), 250)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
async function openMenu() {
|
| 70 |
+
if (props.disabled) return
|
| 71 |
+
open.value = true
|
| 72 |
+
await nextTick()
|
| 73 |
+
inputEl.value?.focus()
|
| 74 |
+
if (!items.value.length && !loading.value) runFetch({ reset: true })
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function closeMenu() {
|
| 78 |
+
open.value = false
|
| 79 |
+
if (abortCtl) abortCtl.abort()
|
| 80 |
+
if (debounceTimer) clearTimeout(debounceTimer)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function onInput(e) {
|
| 84 |
+
query.value = e.target.value
|
| 85 |
+
scheduleFetch()
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function pick(item) {
|
| 89 |
+
emit('update:modelValue', item)
|
| 90 |
+
closeMenu()
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function clear() {
|
| 94 |
+
emit('update:modelValue', null)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async function loadMore() {
|
| 98 |
+
if (loading.value || !hasMore.value) return
|
| 99 |
+
page.value += 1
|
| 100 |
+
await runFetch({ reset: false })
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function onKey(e) {
|
| 104 |
+
if (!open.value) return
|
| 105 |
+
if (e.key === 'Escape') { e.preventDefault(); closeMenu() }
|
| 106 |
+
else if (e.key === 'ArrowDown') { e.preventDefault(); highlight.value = Math.min(highlight.value + 1, items.value.length - 1) }
|
| 107 |
+
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight.value = Math.max(highlight.value - 1, 0) }
|
| 108 |
+
else if (e.key === 'Enter') {
|
| 109 |
+
e.preventDefault()
|
| 110 |
+
if (items.value[highlight.value]) pick(items.value[highlight.value])
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function onDocClick(e) {
|
| 115 |
+
if (!open.value) return
|
| 116 |
+
if (rootEl.value && !rootEl.value.contains(e.target)) closeMenu()
|
| 117 |
+
}
|
| 118 |
+
document.addEventListener('mousedown', onDocClick)
|
| 119 |
+
|
| 120 |
+
onBeforeUnmount(() => {
|
| 121 |
+
document.removeEventListener('mousedown', onDocClick)
|
| 122 |
+
if (abortCtl) abortCtl.abort()
|
| 123 |
+
if (debounceTimer) clearTimeout(debounceTimer)
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
watch(() => props.fetcher, () => {
|
| 127 |
+
items.value = []
|
| 128 |
+
total.value = 0
|
| 129 |
+
page.value = 1
|
| 130 |
+
if (open.value) runFetch({ reset: true })
|
| 131 |
+
})
|
| 132 |
+
</script>
|
| 133 |
+
|
| 134 |
+
<template>
|
| 135 |
+
<div class="picker" :class="['picker-' + kind, { open, disabled }]" ref="rootEl">
|
| 136 |
+
<button type="button" class="picker-pill" :class="{ filled: !!modelValue }"
|
| 137 |
+
@click="open ? closeMenu() : openMenu()" :disabled="disabled"
|
| 138 |
+
:title="modelValue?.name || ''" :aria-haspopup="true" :aria-expanded="open">
|
| 139 |
+
<span class="pill-text">{{ display || placeholder }}</span>
|
| 140 |
+
<span v-if="modelValue" class="pill-clear" role="button"
|
| 141 |
+
aria-label="Clear selection"
|
| 142 |
+
@click.stop="clear">✕</span>
|
| 143 |
+
<i v-else class="icon-caret">▾</i>
|
| 144 |
+
</button>
|
| 145 |
+
|
| 146 |
+
<div v-if="open" class="picker-menu" @keydown="onKey">
|
| 147 |
+
<div class="picker-search">
|
| 148 |
+
<input ref="inputEl" type="text" :value="query" @input="onInput"
|
| 149 |
+
:placeholder="`Search ${kind}…`" @keydown="onKey" />
|
| 150 |
+
</div>
|
| 151 |
+
<div class="picker-list">
|
| 152 |
+
<div v-if="loading && !items.length" class="picker-info">Searching…</div>
|
| 153 |
+
<div v-else-if="errorMsg" class="picker-error">{{ errorMsg }}</div>
|
| 154 |
+
<div v-else-if="!items.length" class="picker-info">No matches.</div>
|
| 155 |
+
<button v-for="(it, i) in items" :key="it.id" type="button"
|
| 156 |
+
class="picker-item" :class="{ hi: i === highlight, picked: modelValue?.id === it.id }"
|
| 157 |
+
@mouseenter="highlight = i" @click="pick(it)">
|
| 158 |
+
<span class="item-label">{{ it.label || it.name || it.id }}</span>
|
| 159 |
+
<span v-if="it.label && it.name && it.label !== it.name" class="item-sub">{{ it.name }}</span>
|
| 160 |
+
</button>
|
| 161 |
+
<button v-if="hasMore" type="button" class="picker-more"
|
| 162 |
+
:disabled="loading" @click="loadMore">
|
| 163 |
+
{{ loading ? 'Loading…' : `Load more (${items.length} / ${total})` }}
|
| 164 |
+
</button>
|
| 165 |
+
<div v-else-if="items.length && !loading" class="picker-info">
|
| 166 |
+
{{ items.length }} of {{ total }}
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</template>
|
| 172 |
+
|
| 173 |
+
<style scoped>
|
| 174 |
+
.picker { position: relative; display: inline-block; max-width: 100%; }
|
| 175 |
+
.picker.disabled { opacity: 0.6; pointer-events: none; }
|
| 176 |
+
|
| 177 |
+
.picker-pill {
|
| 178 |
+
display: inline-flex;
|
| 179 |
+
align-items: center;
|
| 180 |
+
gap: 0.3rem;
|
| 181 |
+
width: max-content;
|
| 182 |
+
max-width: min(40ch, 90vw);
|
| 183 |
+
padding: 0.4em 0.75em;
|
| 184 |
+
border-radius: 999px;
|
| 185 |
+
border: 1px solid var(--primary);
|
| 186 |
+
background: var(--surface);
|
| 187 |
+
color: var(--text);
|
| 188 |
+
font: inherit;
|
| 189 |
+
font-size: 0.9em;
|
| 190 |
+
cursor: pointer;
|
| 191 |
+
white-space: normal;
|
| 192 |
+
overflow-wrap: anywhere;
|
| 193 |
+
text-align: left;
|
| 194 |
+
box-shadow: var(--shadow-sm);
|
| 195 |
+
transition: 0.15s;
|
| 196 |
+
}
|
| 197 |
+
.picker-pill:hover { background: var(--primary-soft); }
|
| 198 |
+
.picker-pill.filled {
|
| 199 |
+
background: var(--primary-soft);
|
| 200 |
+
font-weight: 500;
|
| 201 |
+
}
|
| 202 |
+
.picker-relation .picker-pill {
|
| 203 |
+
border-style: dashed;
|
| 204 |
+
background: var(--surface);
|
| 205 |
+
color: var(--text-muted);
|
| 206 |
+
font-size: 0.82em;
|
| 207 |
+
}
|
| 208 |
+
.picker-relation .picker-pill.filled {
|
| 209 |
+
color: var(--text);
|
| 210 |
+
border-style: solid;
|
| 211 |
+
background: var(--primary-soft);
|
| 212 |
+
}
|
| 213 |
+
.pill-text { flex: 1 1 auto; }
|
| 214 |
+
.pill-clear {
|
| 215 |
+
font-size: 0.8em;
|
| 216 |
+
opacity: 0.6;
|
| 217 |
+
padding: 0 0.25em;
|
| 218 |
+
cursor: pointer;
|
| 219 |
+
}
|
| 220 |
+
.pill-clear:hover { opacity: 1; color: var(--primary-strong); }
|
| 221 |
+
.icon-caret { font-size: 0.75em; opacity: 0.7; }
|
| 222 |
+
|
| 223 |
+
.picker-menu {
|
| 224 |
+
position: absolute;
|
| 225 |
+
top: calc(100% + 0.3rem);
|
| 226 |
+
left: 0;
|
| 227 |
+
z-index: 1000;
|
| 228 |
+
min-width: 18rem;
|
| 229 |
+
max-width: min(90vw, 32rem);
|
| 230 |
+
background: var(--surface);
|
| 231 |
+
border: 1px solid var(--border);
|
| 232 |
+
border-radius: 0.5rem;
|
| 233 |
+
box-shadow: var(--shadow-md);
|
| 234 |
+
overflow: hidden;
|
| 235 |
+
}
|
| 236 |
+
.picker-search { padding: 0.5rem; border-bottom: 1px solid var(--border); }
|
| 237 |
+
.picker-search input {
|
| 238 |
+
width: 100%;
|
| 239 |
+
border: 1px solid var(--border);
|
| 240 |
+
border-radius: 0.4rem;
|
| 241 |
+
padding: 0.35rem 0.5rem;
|
| 242 |
+
background: var(--surface);
|
| 243 |
+
color: var(--text);
|
| 244 |
+
}
|
| 245 |
+
.picker-search input:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
|
| 246 |
+
.picker-list {
|
| 247 |
+
max-height: 18rem;
|
| 248 |
+
overflow-y: auto;
|
| 249 |
+
padding: 0.25rem 0;
|
| 250 |
+
}
|
| 251 |
+
.picker-item {
|
| 252 |
+
display: flex;
|
| 253 |
+
flex-direction: column;
|
| 254 |
+
align-items: flex-start;
|
| 255 |
+
width: 100%;
|
| 256 |
+
background: none;
|
| 257 |
+
border: 0;
|
| 258 |
+
padding: 0.4rem 0.75rem;
|
| 259 |
+
text-align: left;
|
| 260 |
+
color: var(--text);
|
| 261 |
+
cursor: pointer;
|
| 262 |
+
font: inherit;
|
| 263 |
+
font-size: 0.9em;
|
| 264 |
+
}
|
| 265 |
+
.picker-item.hi, .picker-item:hover { background: var(--primary-soft); }
|
| 266 |
+
.picker-item.picked { font-weight: 600; color: var(--primary-strong); }
|
| 267 |
+
.item-label { word-break: break-word; }
|
| 268 |
+
.item-sub { font-size: 0.75em; color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, monospace; }
|
| 269 |
+
.picker-info { padding: 0.5rem 0.75rem; color: var(--text-muted); font-size: 0.85em; }
|
| 270 |
+
.picker-error { padding: 0.5rem 0.75rem; color: #c0392b; font-size: 0.85em; }
|
| 271 |
+
.picker-more {
|
| 272 |
+
width: 100%;
|
| 273 |
+
padding: 0.5rem;
|
| 274 |
+
border: 0;
|
| 275 |
+
border-top: 1px solid var(--border);
|
| 276 |
+
background: var(--surface-muted);
|
| 277 |
+
cursor: pointer;
|
| 278 |
+
font-size: 0.85em;
|
| 279 |
+
color: var(--primary-strong);
|
| 280 |
+
}
|
| 281 |
+
.picker-more:hover:enabled { background: var(--primary-soft); }
|
| 282 |
+
</style>
|
src/frontend/src/components/coins/SearchableRelationDropdown.vue
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import { computed } from 'vue'
|
| 3 |
+
import { searchRelations } from '../../api/coins'
|
| 4 |
+
import SearchablePicker from './SearchablePicker.vue'
|
| 5 |
+
|
| 6 |
+
const props = defineProps({
|
| 7 |
+
modelValue: { type: Object, default: null },
|
| 8 |
+
datasetId: { type: String, required: true },
|
| 9 |
+
placeholder: { type: String, default: 'Pick relation' },
|
| 10 |
+
disabled: { type: Boolean, default: false },
|
| 11 |
+
})
|
| 12 |
+
defineEmits(['update:modelValue'])
|
| 13 |
+
|
| 14 |
+
const fetcher = computed(() => {
|
| 15 |
+
const ds = props.datasetId
|
| 16 |
+
return (opts) => searchRelations(ds, opts)
|
| 17 |
+
})
|
| 18 |
+
</script>
|
| 19 |
+
|
| 20 |
+
<template>
|
| 21 |
+
<SearchablePicker
|
| 22 |
+
:model-value="modelValue"
|
| 23 |
+
:fetcher="fetcher"
|
| 24 |
+
kind="relation"
|
| 25 |
+
:placeholder="placeholder"
|
| 26 |
+
:disabled="disabled || !datasetId"
|
| 27 |
+
@update:modelValue="$emit('update:modelValue', $event)" />
|
| 28 |
+
</template>
|
src/frontend/src/components/coins/TimingPanel.vue
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import { computed } from 'vue'
|
| 3 |
+
|
| 4 |
+
const props = defineProps({
|
| 5 |
+
timing: { type: Object, required: true },
|
| 6 |
+
})
|
| 7 |
+
|
| 8 |
+
const maxMs = computed(() => {
|
| 9 |
+
const t = props.timing
|
| 10 |
+
return Math.max(t.total_ms || 0, t.baseline_estimate_ms || 0, 1)
|
| 11 |
+
})
|
| 12 |
+
|
| 13 |
+
function pct(v) {
|
| 14 |
+
return Math.max(1, (v / maxMs.value) * 100) + '%'
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function fmt(v) {
|
| 18 |
+
if (v == null) return '—'
|
| 19 |
+
return v >= 100 ? v.toFixed(0) + ' ms' : v.toFixed(1) + ' ms'
|
| 20 |
+
}
|
| 21 |
+
</script>
|
| 22 |
+
|
| 23 |
+
<template>
|
| 24 |
+
<div class="timing-panel">
|
| 25 |
+
<h3 class="ui header"><i class="stopwatch icon"></i>Timing & Speedup</h3>
|
| 26 |
+
|
| 27 |
+
<div class="speedup-hero">
|
| 28 |
+
<span class="speedup-val">×{{ (timing.speedup || 0).toFixed(1) }}</span>
|
| 29 |
+
<span class="speedup-label">faster than full-graph inference (Prop. 3.1)</span>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<div class="bars">
|
| 33 |
+
<div class="bar-row">
|
| 34 |
+
<div class="bar-label">{{ timing.step1_label || 'Step 1' }}</div>
|
| 35 |
+
<div class="bar-track">
|
| 36 |
+
<div class="bar-fill step1" :style="{ width: pct(timing.step1_ms) }"></div>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="bar-value">{{ fmt(timing.step1_ms) }}</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div class="bar-row">
|
| 42 |
+
<div class="bar-label">{{ timing.step2_label || 'Step 2' }}</div>
|
| 43 |
+
<div class="bar-track">
|
| 44 |
+
<div class="bar-fill step2" :style="{ width: pct(timing.step2_ms) }"></div>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="bar-value">{{ fmt(timing.step2_ms) }}</div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="bar-row total">
|
| 50 |
+
<div class="bar-label">COINs total</div>
|
| 51 |
+
<div class="bar-track">
|
| 52 |
+
<div class="bar-fill total" :style="{ width: pct(timing.total_ms) }"></div>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="bar-value">{{ fmt(timing.total_ms) }}</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="bar-row baseline">
|
| 58 |
+
<div class="bar-label">{{ timing.baseline_label || 'Baseline estimate' }}</div>
|
| 59 |
+
<div class="bar-track">
|
| 60 |
+
<div class="bar-fill baseline" :style="{ width: pct(timing.baseline_estimate_ms) }"></div>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="bar-value">{{ fmt(timing.baseline_estimate_ms) }}</div>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</template>
|
| 67 |
+
|
| 68 |
+
<style scoped>
|
| 69 |
+
.timing-panel { padding: 0; }
|
| 70 |
+
.speedup-hero {
|
| 71 |
+
display: flex;
|
| 72 |
+
gap: 0.6rem;
|
| 73 |
+
align-items: baseline;
|
| 74 |
+
padding: 0.75rem 1rem;
|
| 75 |
+
background: var(--primary-soft);
|
| 76 |
+
border-radius: 0.5rem;
|
| 77 |
+
margin-bottom: 0.75rem;
|
| 78 |
+
}
|
| 79 |
+
.speedup-val { font-size: 2.2rem; font-weight: 700; color: var(--primary-strong); line-height: 1; }
|
| 80 |
+
.speedup-label { color: var(--text-muted); font-size: 0.9em; }
|
| 81 |
+
|
| 82 |
+
.bars { display: flex; flex-direction: column; gap: 0.4rem; }
|
| 83 |
+
.bar-row {
|
| 84 |
+
display: grid;
|
| 85 |
+
grid-template-columns: minmax(7rem, 14rem) 1fr minmax(4.5rem, auto);
|
| 86 |
+
gap: 0.6rem;
|
| 87 |
+
align-items: center;
|
| 88 |
+
font-size: 0.9em;
|
| 89 |
+
}
|
| 90 |
+
.bar-label { color: var(--text-muted); }
|
| 91 |
+
.bar-row.total .bar-label, .bar-row.total .bar-value { color: var(--text); font-weight: 600; }
|
| 92 |
+
.bar-track {
|
| 93 |
+
height: 12px;
|
| 94 |
+
background: var(--surface-muted);
|
| 95 |
+
border-radius: 6px;
|
| 96 |
+
overflow: hidden;
|
| 97 |
+
}
|
| 98 |
+
.bar-fill { height: 100%; border-radius: 6px; }
|
| 99 |
+
.bar-fill.step1 { background: var(--primary); opacity: 0.55; }
|
| 100 |
+
.bar-fill.step2 { background: var(--primary); opacity: 0.8; }
|
| 101 |
+
.bar-fill.total { background: var(--primary); }
|
| 102 |
+
.bar-fill.baseline { background: var(--text-muted); opacity: 0.5; }
|
| 103 |
+
.bar-value { font-family: ui-monospace, SFMono-Regular, monospace; text-align: right; }
|
| 104 |
+
|
| 105 |
+
@media (max-width: 600px) {
|
| 106 |
+
.bar-row { grid-template-columns: 1fr auto; }
|
| 107 |
+
.bar-row .bar-label { grid-column: 1 / -1; font-size: 0.85em; }
|
| 108 |
+
}
|
| 109 |
+
</style>
|
src/frontend/src/components/home/DemoPreviewCard.vue
CHANGED
|
@@ -1,26 +1,33 @@
|
|
| 1 |
<script setup>
|
|
|
|
|
|
|
| 2 |
defineProps({
|
| 3 |
title: { type: String, required: true },
|
| 4 |
description: { type: String, required: true },
|
| 5 |
icon: { type: String, default: 'flask' },
|
| 6 |
thesisRef: { type: String, default: '' },
|
|
|
|
| 7 |
})
|
| 8 |
</script>
|
| 9 |
|
| 10 |
<template>
|
| 11 |
-
<div
|
|
|
|
| 12 |
<div class="content">
|
| 13 |
<div class="header"><i :class="['icon', icon]"></i>{{ title }}</div>
|
| 14 |
<div class="meta" v-if="thesisRef">{{ thesisRef }}</div>
|
| 15 |
<div class="description">{{ description }}</div>
|
| 16 |
</div>
|
| 17 |
<div class="extra content">
|
| 18 |
-
<span class="ui tiny label">
|
|
|
|
| 19 |
</div>
|
| 20 |
-
</
|
| 21 |
</template>
|
| 22 |
|
| 23 |
<style scoped>
|
| 24 |
.demo-card { width: 100% !important; }
|
| 25 |
.demo-card .header .icon { margin-right: 0.5rem !important; color: var(--primary); }
|
|
|
|
|
|
|
| 26 |
</style>
|
|
|
|
| 1 |
<script setup>
|
| 2 |
+
import { RouterLink } from 'vue-router'
|
| 3 |
+
|
| 4 |
defineProps({
|
| 5 |
title: { type: String, required: true },
|
| 6 |
description: { type: String, required: true },
|
| 7 |
icon: { type: String, default: 'flask' },
|
| 8 |
thesisRef: { type: String, default: '' },
|
| 9 |
+
to: { type: String, default: '' },
|
| 10 |
})
|
| 11 |
</script>
|
| 12 |
|
| 13 |
<template>
|
| 14 |
+
<component :is="to ? RouterLink : 'div'" :to="to || undefined"
|
| 15 |
+
:class="['ui card demo-card', { linked: !!to }]">
|
| 16 |
<div class="content">
|
| 17 |
<div class="header"><i :class="['icon', icon]"></i>{{ title }}</div>
|
| 18 |
<div class="meta" v-if="thesisRef">{{ thesisRef }}</div>
|
| 19 |
<div class="description">{{ description }}</div>
|
| 20 |
</div>
|
| 21 |
<div class="extra content">
|
| 22 |
+
<span v-if="to" class="ui tiny green label">Open demo</span>
|
| 23 |
+
<span v-else class="ui tiny label">Coming soon</span>
|
| 24 |
</div>
|
| 25 |
+
</component>
|
| 26 |
</template>
|
| 27 |
|
| 28 |
<style scoped>
|
| 29 |
.demo-card { width: 100% !important; }
|
| 30 |
.demo-card .header .icon { margin-right: 0.5rem !important; color: var(--primary); }
|
| 31 |
+
.demo-card.linked { cursor: pointer; }
|
| 32 |
+
.demo-card.linked:hover { box-shadow: var(--shadow-md) !important; transform: translateY(-2px); transition: 0.15s; }
|
| 33 |
</style>
|
src/frontend/src/components/layout/NavBar.vue
CHANGED
|
@@ -18,6 +18,7 @@ function close() { menuOpen.value = false }
|
|
| 18 |
<div class="right menu hide-mobile">
|
| 19 |
<RouterLink to="/" class="item" active-class="active" exact-active-class="active">Home</RouterLink>
|
| 20 |
<RouterLink to="/cv" class="item" active-class="active">CV</RouterLink>
|
|
|
|
| 21 |
<div class="item borderless">
|
| 22 |
<ThemeToggle />
|
| 23 |
</div>
|
|
@@ -37,6 +38,7 @@ function close() { menuOpen.value = false }
|
|
| 37 |
<div v-if="menuOpen" class="ui vertical menu mobile-drawer show-mobile">
|
| 38 |
<RouterLink to="/" class="item" @click="close">Home</RouterLink>
|
| 39 |
<RouterLink to="/cv" class="item" @click="close">CV</RouterLink>
|
|
|
|
| 40 |
</div>
|
| 41 |
</nav>
|
| 42 |
</template>
|
|
|
|
| 18 |
<div class="right menu hide-mobile">
|
| 19 |
<RouterLink to="/" class="item" active-class="active" exact-active-class="active">Home</RouterLink>
|
| 20 |
<RouterLink to="/cv" class="item" active-class="active">CV</RouterLink>
|
| 21 |
+
<RouterLink to="/demos/coins" class="item" active-class="active">Demos</RouterLink>
|
| 22 |
<div class="item borderless">
|
| 23 |
<ThemeToggle />
|
| 24 |
</div>
|
|
|
|
| 38 |
<div v-if="menuOpen" class="ui vertical menu mobile-drawer show-mobile">
|
| 39 |
<RouterLink to="/" class="item" @click="close">Home</RouterLink>
|
| 40 |
<RouterLink to="/cv" class="item" @click="close">CV</RouterLink>
|
| 41 |
+
<RouterLink to="/demos/coins" class="item" @click="close">Demos</RouterLink>
|
| 42 |
</div>
|
| 43 |
</nav>
|
| 44 |
</template>
|
src/frontend/src/router/index.js
CHANGED
|
@@ -6,6 +6,12 @@ import NotFoundView from '../views/NotFoundView.vue'
|
|
| 6 |
const routes = [
|
| 7 |
{ path: '/', name: 'home', component: HomeView, meta: { title: 'Andrej Janchevski — PhD Research' } },
|
| 8 |
{ path: '/cv', name: 'cv', component: CVView, meta: { title: 'CV — Andrej Janchevski' } },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { title: 'Not Found' } },
|
| 10 |
]
|
| 11 |
|
|
|
|
| 6 |
const routes = [
|
| 7 |
{ path: '/', name: 'home', component: HomeView, meta: { title: 'Andrej Janchevski — PhD Research' } },
|
| 8 |
{ path: '/cv', name: 'cv', component: CVView, meta: { title: 'CV — Andrej Janchevski' } },
|
| 9 |
+
{
|
| 10 |
+
path: '/demos/coins',
|
| 11 |
+
name: 'demo-coins',
|
| 12 |
+
component: () => import('../views/demos/CoinsView.vue'),
|
| 13 |
+
meta: { title: 'COINs — KG Reasoning Demo' },
|
| 14 |
+
},
|
| 15 |
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { title: 'Not Found' } },
|
| 16 |
]
|
| 17 |
|
src/frontend/src/stores/coinsDemo.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineStore } from 'pinia'
|
| 2 |
+
import { apiError } from '../api/client'
|
| 3 |
+
import { listDatasets, listModels, listQueryStructures, predict } from '../api/coins'
|
| 4 |
+
|
| 5 |
+
export const useCoinsDemoStore = defineStore('coinsDemo', {
|
| 6 |
+
state: () => ({
|
| 7 |
+
datasets: [],
|
| 8 |
+
models: [],
|
| 9 |
+
structures: [],
|
| 10 |
+
registryLoaded: false,
|
| 11 |
+
registryError: '',
|
| 12 |
+
datasetId: '',
|
| 13 |
+
queryStructure: '',
|
| 14 |
+
algorithm: '',
|
| 15 |
+
anchors: {},
|
| 16 |
+
variables: {},
|
| 17 |
+
variablePinned: {},
|
| 18 |
+
relations: {},
|
| 19 |
+
topK: 10,
|
| 20 |
+
loading: false,
|
| 21 |
+
error: '',
|
| 22 |
+
errorCode: '',
|
| 23 |
+
result: null,
|
| 24 |
+
}),
|
| 25 |
+
|
| 26 |
+
getters: {
|
| 27 |
+
selectedStructure(state) {
|
| 28 |
+
return state.structures.find((s) => s.id === state.queryStructure) || null
|
| 29 |
+
},
|
| 30 |
+
allowedAlgorithms(state) {
|
| 31 |
+
if (!state.datasetId || !state.queryStructure) return []
|
| 32 |
+
return state.models.filter(
|
| 33 |
+
(m) =>
|
| 34 |
+
(m.supported_query_structures || []).includes(state.queryStructure) &&
|
| 35 |
+
(m.available_datasets || []).includes(state.datasetId),
|
| 36 |
+
)
|
| 37 |
+
},
|
| 38 |
+
anchorIds() {
|
| 39 |
+
const s = this.selectedStructure
|
| 40 |
+
if (!s) return []
|
| 41 |
+
return s.nodes.filter((n) => n.type === 'anchor').map((n) => n.id)
|
| 42 |
+
},
|
| 43 |
+
variableIds() {
|
| 44 |
+
const s = this.selectedStructure
|
| 45 |
+
if (!s) return []
|
| 46 |
+
return s.nodes.filter((n) => n.type === 'variable').map((n) => n.id)
|
| 47 |
+
},
|
| 48 |
+
relationIds() {
|
| 49 |
+
const s = this.selectedStructure
|
| 50 |
+
if (!s) return []
|
| 51 |
+
return s.edges.map((e) => e.id)
|
| 52 |
+
},
|
| 53 |
+
canReason(state) {
|
| 54 |
+
if (!state.datasetId || !state.queryStructure || !state.algorithm) return false
|
| 55 |
+
if (state.loading) return false
|
| 56 |
+
for (const id of this.anchorIds) {
|
| 57 |
+
if (state.anchors[id]?.id == null) return false
|
| 58 |
+
}
|
| 59 |
+
for (const id of this.relationIds) {
|
| 60 |
+
if (state.relations[id]?.id == null) return false
|
| 61 |
+
}
|
| 62 |
+
for (const id of this.variableIds) {
|
| 63 |
+
if (state.variablePinned[id] && state.variables[id]?.id == null) return false
|
| 64 |
+
}
|
| 65 |
+
return true
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
|
| 69 |
+
actions: {
|
| 70 |
+
async loadRegistry() {
|
| 71 |
+
this.registryError = ''
|
| 72 |
+
try {
|
| 73 |
+
const [datasetsRes, modelsRes, structuresRes] = await Promise.all([
|
| 74 |
+
listDatasets(),
|
| 75 |
+
listModels(),
|
| 76 |
+
listQueryStructures(),
|
| 77 |
+
])
|
| 78 |
+
this.datasets = datasetsRes?.datasets || []
|
| 79 |
+
this.models = modelsRes?.models || []
|
| 80 |
+
this.structures = structuresRes?.query_structures || []
|
| 81 |
+
this.registryLoaded = true
|
| 82 |
+
if (!this.datasetId && this.datasets.length) this.setDataset(this.datasets[0].id)
|
| 83 |
+
if (!this.queryStructure && this.structures.length) this.setStructure(this.structures[0].id)
|
| 84 |
+
} catch (e) {
|
| 85 |
+
this.registryError = apiError(e)
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
setDataset(id) {
|
| 90 |
+
if (this.datasetId === id) return
|
| 91 |
+
this.datasetId = id
|
| 92 |
+
this.anchors = {}
|
| 93 |
+
this.variables = {}
|
| 94 |
+
this.variablePinned = {}
|
| 95 |
+
this.result = null
|
| 96 |
+
this.error = ''
|
| 97 |
+
this.errorCode = ''
|
| 98 |
+
this.autoSelectAlgorithm()
|
| 99 |
+
},
|
| 100 |
+
|
| 101 |
+
setStructure(id) {
|
| 102 |
+
if (this.queryStructure === id) return
|
| 103 |
+
this.queryStructure = id
|
| 104 |
+
this.anchors = {}
|
| 105 |
+
this.variables = {}
|
| 106 |
+
this.variablePinned = {}
|
| 107 |
+
this.relations = {}
|
| 108 |
+
this.result = null
|
| 109 |
+
this.error = ''
|
| 110 |
+
this.errorCode = ''
|
| 111 |
+
this.autoSelectAlgorithm()
|
| 112 |
+
},
|
| 113 |
+
|
| 114 |
+
setAlgorithm(algorithm) {
|
| 115 |
+
this.algorithm = algorithm
|
| 116 |
+
},
|
| 117 |
+
|
| 118 |
+
autoSelectAlgorithm() {
|
| 119 |
+
const allowed = this.allowedAlgorithms
|
| 120 |
+
if (!allowed.length) {
|
| 121 |
+
this.algorithm = ''
|
| 122 |
+
return
|
| 123 |
+
}
|
| 124 |
+
if (!allowed.find((m) => m.algorithm === this.algorithm)) {
|
| 125 |
+
this.algorithm = allowed[0].algorithm
|
| 126 |
+
}
|
| 127 |
+
},
|
| 128 |
+
|
| 129 |
+
setAnchor(id, entity) {
|
| 130 |
+
if (entity) this.anchors = { ...this.anchors, [id]: entity }
|
| 131 |
+
else {
|
| 132 |
+
const { [id]: _removed, ...rest } = this.anchors
|
| 133 |
+
this.anchors = rest
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
|
| 137 |
+
setRelation(id, relation) {
|
| 138 |
+
if (relation) this.relations = { ...this.relations, [id]: relation }
|
| 139 |
+
else {
|
| 140 |
+
const { [id]: _removed, ...rest } = this.relations
|
| 141 |
+
this.relations = rest
|
| 142 |
+
}
|
| 143 |
+
},
|
| 144 |
+
|
| 145 |
+
setVariable(id, entity) {
|
| 146 |
+
if (entity) this.variables = { ...this.variables, [id]: entity }
|
| 147 |
+
else {
|
| 148 |
+
const { [id]: _removed, ...rest } = this.variables
|
| 149 |
+
this.variables = rest
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
|
| 153 |
+
setVariablePinned(id, pinned) {
|
| 154 |
+
this.variablePinned = { ...this.variablePinned, [id]: pinned }
|
| 155 |
+
if (!pinned) this.setVariable(id, null)
|
| 156 |
+
},
|
| 157 |
+
|
| 158 |
+
setTopK(k) {
|
| 159 |
+
this.topK = k
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
async reason() {
|
| 163 |
+
if (!this.canReason) return
|
| 164 |
+
this.loading = true
|
| 165 |
+
this.error = ''
|
| 166 |
+
this.errorCode = ''
|
| 167 |
+
this.result = null
|
| 168 |
+
const payload = {
|
| 169 |
+
dataset_id: this.datasetId,
|
| 170 |
+
algorithm: this.algorithm,
|
| 171 |
+
query_structure: this.queryStructure,
|
| 172 |
+
anchors: Object.fromEntries(
|
| 173 |
+
Object.entries(this.anchors).map(([k, v]) => [k, v.id]),
|
| 174 |
+
),
|
| 175 |
+
relations: Object.fromEntries(
|
| 176 |
+
Object.entries(this.relations).map(([k, v]) => [k, v.id]),
|
| 177 |
+
),
|
| 178 |
+
top_k: this.topK,
|
| 179 |
+
}
|
| 180 |
+
const pinnedVars = Object.fromEntries(
|
| 181 |
+
Object.entries(this.variables)
|
| 182 |
+
.filter(([k]) => this.variablePinned[k])
|
| 183 |
+
.map(([k, v]) => [k, v.id]),
|
| 184 |
+
)
|
| 185 |
+
if (Object.keys(pinnedVars).length) payload.variables = pinnedVars
|
| 186 |
+
|
| 187 |
+
try {
|
| 188 |
+
this.result = await predict(payload)
|
| 189 |
+
} catch (e) {
|
| 190 |
+
const err = e?.response?.data?.error
|
| 191 |
+
this.errorCode = err?.code || ''
|
| 192 |
+
this.error = err?.message || apiError(e)
|
| 193 |
+
} finally {
|
| 194 |
+
this.loading = false
|
| 195 |
+
}
|
| 196 |
+
},
|
| 197 |
+
},
|
| 198 |
+
})
|
src/frontend/src/views/HomeView.vue
CHANGED
|
@@ -12,6 +12,7 @@ const demos = [
|
|
| 12 |
description: 'Quadratically-accelerated link prediction and conjunctive query answering with community-informed graph coarsening.',
|
| 13 |
icon: 'project diagram',
|
| 14 |
thesisRef: 'Thesis § 3.1',
|
|
|
|
| 15 |
},
|
| 16 |
{
|
| 17 |
title: 'MultiProxAn — Graph Generation',
|
|
|
|
| 12 |
description: 'Quadratically-accelerated link prediction and conjunctive query answering with community-informed graph coarsening.',
|
| 13 |
icon: 'project diagram',
|
| 14 |
thesisRef: 'Thesis § 3.1',
|
| 15 |
+
to: '/demos/coins',
|
| 16 |
},
|
| 17 |
{
|
| 18 |
title: 'MultiProxAn — Graph Generation',
|
src/frontend/src/views/demos/CoinsView.vue
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script setup>
|
| 2 |
+
import { computed, onMounted, ref } from 'vue'
|
| 3 |
+
import PageSection from '../../components/layout/PageSection.vue'
|
| 4 |
+
import QueryStructurePicker from '../../components/coins/QueryStructurePicker.vue'
|
| 5 |
+
import QueryGraph from '../../components/coins/QueryGraph.vue'
|
| 6 |
+
import SearchableEntityDropdown from '../../components/coins/SearchableEntityDropdown.vue'
|
| 7 |
+
import SearchableRelationDropdown from '../../components/coins/SearchableRelationDropdown.vue'
|
| 8 |
+
import AlgorithmSelector from '../../components/coins/AlgorithmSelector.vue'
|
| 9 |
+
import ResultsDashboard from '../../components/coins/ResultsDashboard.vue'
|
| 10 |
+
import { useCoinsDemoStore } from '../../stores/coinsDemo'
|
| 11 |
+
import { sampleQuery } from '../../api/coins'
|
| 12 |
+
|
| 13 |
+
const store = useCoinsDemoStore()
|
| 14 |
+
const prefillError = ref('')
|
| 15 |
+
const prefilling = ref(false)
|
| 16 |
+
|
| 17 |
+
onMounted(() => {
|
| 18 |
+
if (!store.registryLoaded) store.loadRegistry()
|
| 19 |
+
})
|
| 20 |
+
|
| 21 |
+
const selectedDataset = computed(() =>
|
| 22 |
+
store.datasets.find((d) => d.id === store.datasetId) || null,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
function setAnchor(id, val) { store.setAnchor(id, val) }
|
| 26 |
+
function setRelation(id, val) { store.setRelation(id, val) }
|
| 27 |
+
function setVariable(id, val) { store.setVariable(id, val) }
|
| 28 |
+
|
| 29 |
+
async function prefillRandom() {
|
| 30 |
+
if (!store.datasetId || !store.queryStructure) return
|
| 31 |
+
prefillError.value = ''
|
| 32 |
+
prefilling.value = true
|
| 33 |
+
try {
|
| 34 |
+
const seed = new Date().toISOString().slice(0, 10)
|
| 35 |
+
const res = await sampleQuery(store.datasetId, store.queryStructure, 1, seed)
|
| 36 |
+
const query = (res?.queries || [])[0]
|
| 37 |
+
if (!query) {
|
| 38 |
+
prefillError.value = 'Could not find a valid query — try again.'
|
| 39 |
+
return
|
| 40 |
+
}
|
| 41 |
+
for (const [id, entity] of Object.entries(query.anchors)) {
|
| 42 |
+
store.setAnchor(id, entity)
|
| 43 |
+
}
|
| 44 |
+
for (const [id, relation] of Object.entries(query.relations)) {
|
| 45 |
+
store.setRelation(id, relation)
|
| 46 |
+
}
|
| 47 |
+
if (query.variables) {
|
| 48 |
+
for (const [id, entity] of Object.entries(query.variables)) {
|
| 49 |
+
if (store.variablePinned[id]) {
|
| 50 |
+
store.setVariable(id, entity)
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
} catch (e) {
|
| 55 |
+
prefillError.value = e?.message || 'Prefill failed'
|
| 56 |
+
} finally {
|
| 57 |
+
prefilling.value = false
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const errorBanner = computed(() => {
|
| 62 |
+
if (!store.error) return null
|
| 63 |
+
if (store.errorCode === 'INFERENCE_BUSY') {
|
| 64 |
+
return { kind: 'warning', text: 'Another inference is running — retry in a moment.' }
|
| 65 |
+
}
|
| 66 |
+
if (store.errorCode === 'MODEL_UNAVAILABLE') {
|
| 67 |
+
return { kind: 'error', text: `${store.error} Try a different algorithm or dataset.` }
|
| 68 |
+
}
|
| 69 |
+
if (store.errorCode === 'INFERENCE_ERROR') {
|
| 70 |
+
return { kind: 'error', text: `No valid answers: ${store.error}` }
|
| 71 |
+
}
|
| 72 |
+
return { kind: 'error', text: store.error }
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
const structure = computed(() => store.selectedStructure)
|
| 76 |
+
</script>
|
| 77 |
+
|
| 78 |
+
<template>
|
| 79 |
+
<div class="coins-demo">
|
| 80 |
+
<header class="coins-head">
|
| 81 |
+
<h1 class="ui header">
|
| 82 |
+
<i class="project diagram icon"></i>
|
| 83 |
+
COINs — Knowledge-Graph Reasoning
|
| 84 |
+
</h1>
|
| 85 |
+
<p class="muted">
|
| 86 |
+
Community-informed graph coarsening accelerates link prediction and conjunctive query answering.
|
| 87 |
+
Pick a dataset, assemble a query by filling anchors and relations, then hit
|
| 88 |
+
<strong>Reason</strong> to score candidate entities against the KG.
|
| 89 |
+
Thesis § 3.1.
|
| 90 |
+
</p>
|
| 91 |
+
</header>
|
| 92 |
+
|
| 93 |
+
<div v-if="store.registryError" class="ui negative message">
|
| 94 |
+
<div class="header">Backend unreachable</div>
|
| 95 |
+
<p>{{ store.registryError }}</p>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div v-else-if="!store.registryLoaded" class="ui placeholder">
|
| 99 |
+
<div class="line"></div><div class="line"></div><div class="line"></div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<template v-else>
|
| 103 |
+
<PageSection title="Setup" :collapsible="false">
|
| 104 |
+
<div class="setup-row">
|
| 105 |
+
<div class="setup-col">
|
| 106 |
+
<label class="setup-label">Dataset</label>
|
| 107 |
+
<select class="setup-control" :value="store.datasetId"
|
| 108 |
+
@change="store.setDataset($event.target.value)">
|
| 109 |
+
<option v-for="d in store.datasets" :key="d.id" :value="d.id">
|
| 110 |
+
{{ d.name }} ({{ d.num_entities }} entities · {{ d.num_relations }} relations)
|
| 111 |
+
</option>
|
| 112 |
+
</select>
|
| 113 |
+
<div v-if="selectedDataset" class="muted tiny">{{ selectedDataset.description }}</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div class="setup-col">
|
| 117 |
+
<AlgorithmSelector
|
| 118 |
+
:algorithms="store.allowedAlgorithms"
|
| 119 |
+
:model-value="store.algorithm"
|
| 120 |
+
@update:modelValue="store.setAlgorithm($event)" />
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<div class="setup-col">
|
| 124 |
+
<label class="setup-label">Top-K: {{ store.topK }}</label>
|
| 125 |
+
<input type="range" min="1" max="10" step="1" class="setup-range"
|
| 126 |
+
:value="store.topK"
|
| 127 |
+
@input="store.setTopK(Number($event.target.value))" />
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</PageSection>
|
| 131 |
+
|
| 132 |
+
<PageSection title="Query structure" :collapsible="false">
|
| 133 |
+
<QueryStructurePicker
|
| 134 |
+
:structures="store.structures"
|
| 135 |
+
:model-value="store.queryStructure"
|
| 136 |
+
@update:modelValue="store.setStructure($event)" />
|
| 137 |
+
</PageSection>
|
| 138 |
+
|
| 139 |
+
<PageSection title="Query graph" :collapsible="false">
|
| 140 |
+
<div class="prefill-row">
|
| 141 |
+
<button type="button" class="prefill-btn" @click="prefillRandom"
|
| 142 |
+
:disabled="prefilling || !store.datasetId">
|
| 143 |
+
<i class="random icon"></i>
|
| 144 |
+
{{ prefilling ? 'Prefilling…' : 'Prefill with a random query' }}
|
| 145 |
+
</button>
|
| 146 |
+
<span v-if="prefillError" class="muted error">{{ prefillError }}</span>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div v-if="structure" class="graph-wrap">
|
| 150 |
+
<QueryGraph :structure="structure" mode="edit"
|
| 151 |
+
:cell-min-width="260" :cell-min-height="130" :pad="44">
|
| 152 |
+
<template v-for="n in structure.nodes.filter((n) => n.type === 'anchor')"
|
| 153 |
+
:key="'slot-' + n.id" #[`node-${n.id}`]>
|
| 154 |
+
<div class="slot-node">
|
| 155 |
+
<div class="slot-caption">{{ n.label || n.id }}</div>
|
| 156 |
+
<SearchableEntityDropdown
|
| 157 |
+
:dataset-id="store.datasetId"
|
| 158 |
+
:model-value="store.anchors[n.id] || null"
|
| 159 |
+
placeholder="Pick anchor"
|
| 160 |
+
@update:modelValue="setAnchor(n.id, $event)" />
|
| 161 |
+
</div>
|
| 162 |
+
</template>
|
| 163 |
+
|
| 164 |
+
<template v-for="n in structure.nodes.filter((n) => n.type === 'variable')"
|
| 165 |
+
:key="'slotv-' + n.id" #[`node-${n.id}`]>
|
| 166 |
+
<div class="slot-node">
|
| 167 |
+
<div class="slot-caption">
|
| 168 |
+
<label class="pin-toggle">
|
| 169 |
+
<input type="checkbox"
|
| 170 |
+
:checked="!!store.variablePinned[n.id]"
|
| 171 |
+
@change="store.setVariablePinned(n.id, $event.target.checked)" />
|
| 172 |
+
Pin {{ n.label || n.id }}
|
| 173 |
+
</label>
|
| 174 |
+
</div>
|
| 175 |
+
<SearchableEntityDropdown v-if="store.variablePinned[n.id]"
|
| 176 |
+
:dataset-id="store.datasetId"
|
| 177 |
+
:model-value="store.variables[n.id] || null"
|
| 178 |
+
placeholder="Pick variable"
|
| 179 |
+
@update:modelValue="setVariable(n.id, $event)" />
|
| 180 |
+
<span v-else class="let-model-pick">Let model sample</span>
|
| 181 |
+
</div>
|
| 182 |
+
</template>
|
| 183 |
+
|
| 184 |
+
<template #node-t>
|
| 185 |
+
<div class="slot-target" title="Query target">
|
| 186 |
+
<i class="question circle icon"></i>
|
| 187 |
+
<span>{{ structure.nodes.find(n => n.id === 't')?.label || 'target' }}</span>
|
| 188 |
+
</div>
|
| 189 |
+
</template>
|
| 190 |
+
|
| 191 |
+
<template v-for="e in structure.edges" :key="'slote-' + e.id" #[`edge-${e.id}`]>
|
| 192 |
+
<SearchableRelationDropdown
|
| 193 |
+
:dataset-id="store.datasetId"
|
| 194 |
+
:model-value="store.relations[e.id] || null"
|
| 195 |
+
:placeholder="e.label || 'Pick relation'"
|
| 196 |
+
@update:modelValue="setRelation(e.id, $event)" />
|
| 197 |
+
</template>
|
| 198 |
+
</QueryGraph>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<div class="reason-bar">
|
| 202 |
+
<button type="button" class="ui big primary button reason-btn"
|
| 203 |
+
:disabled="!store.canReason"
|
| 204 |
+
@click="store.reason()">
|
| 205 |
+
<i v-if="store.loading" class="spinner loading icon"></i>
|
| 206 |
+
<i v-else class="play icon"></i>
|
| 207 |
+
{{ store.loading ? 'Reasoning…' : 'Reason' }}
|
| 208 |
+
</button>
|
| 209 |
+
<div v-if="!store.canReason && !store.loading" class="muted reason-hint">
|
| 210 |
+
Fill all anchor and relation slots first.
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</PageSection>
|
| 214 |
+
|
| 215 |
+
<div v-if="errorBanner" class="ui message" :class="errorBanner.kind">
|
| 216 |
+
<p>{{ errorBanner.text }}</p>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<PageSection v-if="store.result" title="Results" :collapsible="false">
|
| 220 |
+
<ResultsDashboard :result="store.result" />
|
| 221 |
+
</PageSection>
|
| 222 |
+
</template>
|
| 223 |
+
</div>
|
| 224 |
+
</template>
|
| 225 |
+
|
| 226 |
+
<style scoped>
|
| 227 |
+
.coins-demo { padding: 1rem 0 3rem; }
|
| 228 |
+
.coins-head { margin-bottom: 1.5rem; }
|
| 229 |
+
.coins-head h1.header { margin-bottom: 0.5rem; }
|
| 230 |
+
.muted { color: var(--text-muted); }
|
| 231 |
+
.muted.tiny { font-size: 0.8em; }
|
| 232 |
+
.muted.error { color: #c0392b; }
|
| 233 |
+
|
| 234 |
+
.setup-row {
|
| 235 |
+
display: grid;
|
| 236 |
+
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
| 237 |
+
gap: 1rem 1.5rem;
|
| 238 |
+
align-items: start;
|
| 239 |
+
}
|
| 240 |
+
.setup-col {
|
| 241 |
+
display: flex;
|
| 242 |
+
flex-direction: column;
|
| 243 |
+
gap: 0.3rem;
|
| 244 |
+
min-width: 0;
|
| 245 |
+
}
|
| 246 |
+
.setup-label {
|
| 247 |
+
font-size: 0.8em;
|
| 248 |
+
color: var(--text-muted);
|
| 249 |
+
text-transform: uppercase;
|
| 250 |
+
letter-spacing: 0.04em;
|
| 251 |
+
line-height: 1.2;
|
| 252 |
+
min-height: 1.2em;
|
| 253 |
+
}
|
| 254 |
+
.setup-control,
|
| 255 |
+
.setup-range {
|
| 256 |
+
height: 2.4rem;
|
| 257 |
+
padding: 0 0.6rem;
|
| 258 |
+
border: 1px solid var(--border);
|
| 259 |
+
border-radius: 0.4rem;
|
| 260 |
+
background: var(--surface);
|
| 261 |
+
color: var(--text);
|
| 262 |
+
font: inherit;
|
| 263 |
+
box-sizing: border-box;
|
| 264 |
+
width: 100%;
|
| 265 |
+
}
|
| 266 |
+
.setup-range { padding: 0 0.4rem; }
|
| 267 |
+
.setup-range:focus,
|
| 268 |
+
.setup-control:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
|
| 269 |
+
|
| 270 |
+
.prefill-row { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
| 271 |
+
.prefill-btn {
|
| 272 |
+
display: inline-flex;
|
| 273 |
+
align-items: center;
|
| 274 |
+
gap: 0.4rem;
|
| 275 |
+
padding: 0.5rem 0.9rem;
|
| 276 |
+
border-radius: 0.5rem;
|
| 277 |
+
border: 1px solid var(--primary);
|
| 278 |
+
background: var(--primary-soft);
|
| 279 |
+
color: var(--primary-strong);
|
| 280 |
+
font: inherit;
|
| 281 |
+
font-size: 0.9em;
|
| 282 |
+
font-weight: 600;
|
| 283 |
+
cursor: pointer;
|
| 284 |
+
transition: 0.15s;
|
| 285 |
+
}
|
| 286 |
+
.prefill-btn:hover:enabled { background: var(--primary); color: white; }
|
| 287 |
+
.prefill-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
| 288 |
+
.prefill-btn i.icon { margin: 0; }
|
| 289 |
+
|
| 290 |
+
.graph-wrap {
|
| 291 |
+
background: var(--surface);
|
| 292 |
+
border: 1px solid var(--border);
|
| 293 |
+
border-radius: 0.6rem;
|
| 294 |
+
padding: 2rem 1rem;
|
| 295 |
+
margin: 0 auto 1.5rem;
|
| 296 |
+
min-height: 320px;
|
| 297 |
+
display: flex;
|
| 298 |
+
align-items: center;
|
| 299 |
+
justify-content: center;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.slot-node {
|
| 303 |
+
position: relative;
|
| 304 |
+
display: inline-flex;
|
| 305 |
+
flex-direction: column;
|
| 306 |
+
align-items: center;
|
| 307 |
+
}
|
| 308 |
+
.slot-caption {
|
| 309 |
+
position: absolute;
|
| 310 |
+
bottom: calc(100% + 0.25rem);
|
| 311 |
+
left: 50%;
|
| 312 |
+
transform: translateX(-50%);
|
| 313 |
+
font-size: 0.72em;
|
| 314 |
+
color: var(--text-muted);
|
| 315 |
+
text-transform: uppercase;
|
| 316 |
+
letter-spacing: 0.04em;
|
| 317 |
+
white-space: nowrap;
|
| 318 |
+
}
|
| 319 |
+
.pin-toggle { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; pointer-events: auto; }
|
| 320 |
+
.let-model-pick {
|
| 321 |
+
display: inline-block;
|
| 322 |
+
padding: 0.4em 0.9em;
|
| 323 |
+
border-radius: 999px;
|
| 324 |
+
border: 1px dashed var(--border);
|
| 325 |
+
color: var(--text-muted);
|
| 326 |
+
font-size: 0.85em;
|
| 327 |
+
background: var(--surface-muted);
|
| 328 |
+
font-style: italic;
|
| 329 |
+
white-space: nowrap;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.slot-target {
|
| 333 |
+
display: inline-flex;
|
| 334 |
+
align-items: center;
|
| 335 |
+
gap: 0.3rem;
|
| 336 |
+
padding: 0.5em 0.9em;
|
| 337 |
+
border-radius: 999px;
|
| 338 |
+
background: var(--surface);
|
| 339 |
+
border: 2px dashed var(--primary);
|
| 340 |
+
font-weight: 700;
|
| 341 |
+
color: var(--primary-strong);
|
| 342 |
+
}
|
| 343 |
+
.slot-target i.icon { margin: 0; }
|
| 344 |
+
|
| 345 |
+
.reason-bar {
|
| 346 |
+
display: flex;
|
| 347 |
+
gap: 0.75rem;
|
| 348 |
+
align-items: center;
|
| 349 |
+
justify-content: center;
|
| 350 |
+
flex-wrap: wrap;
|
| 351 |
+
margin: 1rem 0;
|
| 352 |
+
}
|
| 353 |
+
.reason-btn { background: var(--primary) !important; }
|
| 354 |
+
.reason-btn:hover:enabled { background: var(--primary-strong) !important; }
|
| 355 |
+
.reason-hint { font-size: 0.85em; }
|
| 356 |
+
|
| 357 |
+
.ui.message { margin: 1rem 0 !important; }
|
| 358 |
+
</style>
|