You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
autoflow-web-console/src/components/atoms/organisms/CompareRunDialog.vue

388 lines
12 KiB

<script setup lang="ts">
import {
computed,
ref,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import Plotly from "plotly.js-dist-min";
/** ===== Types ===== */
type MetricKV = { key: string; value: number };
type RunDetailType = {
info: any;
data: { metrics: MetricKV[]; params?: any[]; tags?: any[] };
};
/** ===== Props / Emits ===== */
const props = withDefaults(
defineProps<{
modelValue: boolean;
items: Array<{ value: string; label: string }>;
selectedRunIds: string[];
selectedMetricKeys: string[];
ensureRunDetail: (runId: string) => Promise<RunDetailType | null>;
runDetailCache: Map<string, RunDetailType>;
compareChartMode?: "byMetric" | "byRun";
normalizeValues?: boolean;
baselineRunId?: string | null;
}>(),
{
modelValue: false,
items: () => [],
selectedRunIds: () => [],
selectedMetricKeys: () => [],
compareChartMode: "byMetric",
normalizeValues: false,
baselineRunId: null,
},
);
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
(e: "update:selectedRunIds", v: string[]): void;
(e: "update:selectedMetricKeys", v: string[]): void;
}>();
/** ===== local refs ===== */
const dialogOpen = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const selectedRunIdsProxy = computed({
get: () => props.selectedRunIds,
set: (v: string[]) => emit("update:selectedRunIds", v),
});
const selectedMetricKeysProxy = computed({
get: () => props.selectedMetricKeys,
set: (v: string[]) => emit("update:selectedMetricKeys", v),
});
const elCompare = ref<HTMLDivElement | null>(null);
const loading = ref(false);
/** ===== helpers ===== */
const fmtNumber = (v: number | null, digits = 3) => {
if (v == null || !Number.isFinite(v)) return "";
const abs = Math.abs(v);
if (abs >= 1000) return v.toLocaleString();
if (abs >= 1) return v.toFixed(digits);
if (abs >= 0.01) return v.toFixed(digits + 1);
return v.toExponential(2);
};
const normalizeArray = (vals: (number | null)[]) => {
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
if (xs.length === 0) return vals;
const min = Math.min(...xs),
max = Math.max(...xs);
if (max === min) return vals.map((v) => (v == null ? v : 1));
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
};
const labelOfRun = (r: RunDetailType) => r.info.run_name || r.info.run_id;
const valueOf = (run: RunDetailType, key: string): number | null => {
const m = run.data.metrics.find((x) => x.key === key)?.value;
return Number.isFinite(m as number) ? Number(m) : null;
};
/** ===== derived ===== */
const compareRuns = computed<RunDetailType[]>(
() =>
selectedRunIdsProxy.value
.map((id) => props.runDetailCache.get(id))
.filter(Boolean) as RunDetailType[],
);
const metricsKeySetByRun = computed(() =>
compareRuns.value.map((r) => new Set(r.data.metrics.map((m) => m.key))),
);
const commonMetricKeys = computed<string[]>(() => {
if (metricsKeySetByRun.value.length === 0) return [];
const [first, ...rest] = metricsKeySetByRun.value;
let inter = new Set(first);
rest.forEach((s) => (inter = new Set([...inter].filter((k) => s.has(k)))));
return Array.from(inter).sort();
});
const activeMetricKeys = computed<string[]>(() =>
selectedMetricKeysProxy.value.length
? selectedMetricKeysProxy.value
: commonMetricKeys.value,
);
/** ===== chart ===== */
const commonLayout: Partial<any> = {
autosize: true,
margin: { t: 28, r: 12, b: 56, l: 56 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
font: { color: "#E0E0E0" },
xaxis: { automargin: true, gridcolor: "rgba(255,255,255,0.08)" },
yaxis: {
rangemode: "tozero",
gridcolor: "rgba(255,255,255,0.08)",
automargin: true,
},
legend: {
orientation: "h",
x: 0,
y: 1.12,
yanchor: "bottom",
xanchor: "left",
},
};
function createTracesByMetric(metricKeys: string[], runsData: RunDetailType[]) {
const runLabels = runsData.map(labelOfRun);
return runsData.map((run, i) => {
const yRaw = metricKeys.map((k) => valueOf(run, k));
const y = props.normalizeValues ? normalizeArray(yRaw) : yRaw;
const text = yRaw.map((v) => fmtNumber(v));
return {
type: "bar",
name: runLabels[i],
x: metricKeys,
y,
text: props.normalizeValues ? undefined : text,
textposition: "auto",
customdata: text,
hovertemplate: `${runLabels[i]}<br>%{x}: %{customdata}<extra></extra>`,
};
});
}
function createTracesByRun(metricKeys: string[], runsData: RunDetailType[]) {
const rowLabels = runsData.map(labelOfRun);
return metricKeys.map((key) => {
const yRaw = rowLabels.map((_, i) => valueOf(runsData[i], key));
const y = props.normalizeValues ? normalizeArray(yRaw) : yRaw;
const text = yRaw.map((v) => fmtNumber(v));
return {
type: "bar",
name: key,
x: rowLabels,
y,
text: props.normalizeValues ? undefined : text,
textposition: "auto",
customdata: text,
hovertemplate: `${key}: %{customdata}<extra>%{x}</extra>`,
};
});
}
function drawCompareChart() {
if (!elCompare.value) return;
const metricKeys = activeMetricKeys.value;
const runsData = compareRuns.value;
if (!metricKeys.length || !runsData.length) {
elCompare.value.innerHTML =
'<div style="padding:16px;color:#999;text-align:center;">No Data</div>';
return;
}
const traces =
props.compareChartMode === "byMetric"
? createTracesByMetric(metricKeys, runsData)
: createTracesByRun(metricKeys, runsData);
if (props.compareChartMode === "byMetric") {
const varianceOrder = metricKeys
.map((k, idx) => {
const vals = traces
.map((t: any) => t.y[idx])
.filter((v: any) => v != null) as number[];
const min = Math.min(...vals),
max = Math.max(...vals);
return { k, spread: max - min };
})
.sort((a, b) => b.spread - a.spread)
.map((v) => v.k);
traces.forEach((t: any) => {
t.x = varianceOrder;
t.y = varianceOrder.map((mk: string) => t.y[metricKeys.indexOf(mk)]);
if (t.text)
t.text = varianceOrder.map(
(mk: string) => t.text[metricKeys.indexOf(mk)],
);
if (t.customdata)
t.customdata = varianceOrder.map(
(mk: string) => t.customdata[metricKeys.indexOf(mk)],
);
});
}
Plotly.react(
elCompare.value,
traces as any,
{ ...commonLayout, barmode: "group" },
{ displayModeBar: false, responsive: true },
);
}
/** ===== load & watch ===== */
async function loadCompareData() {
if (!dialogOpen.value) return;
loading.value = true;
try {
await Promise.all(selectedRunIdsProxy.value.map(props.ensureRunDetail));
if (selectedMetricKeysProxy.value.length === 0) {
selectedMetricKeysProxy.value = commonMetricKeys.value.slice(0, 6);
}
await nextTick();
drawCompareChart();
} finally {
loading.value = false;
}
}
watch(
() => dialogOpen.value,
(open) => {
if (open) loadCompareData();
},
);
watch([selectedRunIdsProxy, selectedMetricKeysProxy], async () => {
if (!dialogOpen.value) return;
await loadCompareData();
});
const onResize = () => {
if (elCompare.value) Plotly.Plots.resize(elCompare.value);
};
onMounted(() => window.addEventListener("resize", onResize));
onBeforeUnmount(() => window.removeEventListener("resize", onResize));
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="1000">
<v-card class="rounded-lg overflow-hidden">
<!-- 헤더: Deploy Model 스타일 적용 -->
<v-card-title
class="text-white font-weight-bold text-h6"
style="background-color: #1976d2"
>
Compare Runs
</v-card-title>
<!-- 상단: 선택 섹션 -->
<v-card-text class="pa-6">
<v-row dense class="mb-2">
<v-col cols="12">
<v-subheader class="font-weight-medium mb-2"
>Select Runs</v-subheader
>
<v-autocomplete
v-model="selectedRunIdsProxy"
:items="items"
item-title="label"
item-value="value"
chips
multiple
closable-chips
variant="outlined"
density="comfortable"
hide-details
:disabled="loading"
placeholder="Select runs to compare"
/>
</v-col>
</v-row>
<v-expand-transition>
<v-alert
v-if="!loading && selectedRunIdsProxy.length < 2"
type="info"
variant="tonal"
density="compact"
class="mt-2"
>
2 이상의 Run을 선택해 주세요.
</v-alert>
</v-expand-transition>
<v-row dense class="mt-4">
<v-col cols="12">
<v-subheader class="font-weight-medium mb-2">Metrics</v-subheader>
<v-autocomplete
v-model="selectedMetricKeysProxy"
:items="activeMetricKeys.length ? activeMetricKeys : []"
:menu-props="{ maxHeight: 360 }"
chips
multiple
closable-chips
variant="outlined"
density="comfortable"
hide-details
:disabled="loading || compareRuns.length === 0"
placeholder="Select metrics (empty = all common)"
/>
<div class="d-flex align-center mt-2 ga-2">
<v-chip size="small" color="primary" variant="tonal">
Mode:
{{
props.compareChartMode === "byMetric" ? "By Metric" : "By Run"
}}
</v-chip>
<v-chip size="small" color="secondary" variant="tonal">
{{ props.normalizeValues ? "Normalized" : "Raw values" }}
</v-chip>
<v-chip
v-if="props.baselineRunId"
size="small"
color="info"
variant="tonal"
>
Baseline: {{ props.baselineRunId }}
</v-chip>
</div>
</v-col>
</v-row>
</v-card-text>
<!-- 차트 & 테이블 섹션 ( 카드로 감싸기) -->
<v-card-text class="pt-0 px-6 pb-2">
<v-card variant="tonal" class="mb-4">
<v-card-title class="py-2 px-4">Metrics (grouped bar)</v-card-title>
<v-divider />
<v-card-text class="px-4">
<div v-if="loading" class="my-2">
<v-progress-linear indeterminate />
</div>
<div ref="elCompare" style="width: 100%; height: 420px"></div>
</v-card-text>
</v-card>
<v-card variant="tonal">
<v-card-title class="py-2 px-4">Comparison Table</v-card-title>
<v-divider />
<v-card-text class="px-0">
<v-table density="comfortable">
<thead>
<tr>
<th style="width: 28%">Run</th>
<th v-for="k in activeMetricKeys" :key="k">{{ k }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in compareRuns" :key="r.info.run_id">
<td class="text-no-wrap">
{{ r.info.run_name || r.info.run_id }}
</td>
<td v-for="k in activeMetricKeys" :key="k">
{{ r.data.metrics.find((m) => m.key === k)?.value ?? "—" }}
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-card-text>
<!-- 액션 -->
<v-card-actions class="justify-end">
<v-btn variant="text" class="text-white" @click="dialogOpen = false">
CLOSE
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped></style>