refactor(admin-web): render citation pie as interactive SVG with hover tooltip
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Has been cancelled
Deployment Config CI / Deployment Config (push) Successful in 21s

Replace the conic-gradient circle with an SVG path-per-slice chart so
slices can lift on hover, dim siblings, and surface a follow-cursor
tooltip with the platform name and share. Falls back to the original
gradient circle when there are no segments to render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 11:46:35 +08:00
parent b345ee26e4
commit 9727077dfa
+154 -15
View File
@@ -13,7 +13,7 @@ import type {
MonitoringPlatformAuthorizationStatus, MonitoringPlatformAuthorizationStatus,
} from "@geo/shared-types"; } from "@geo/shared-types";
import { aiPlatformCatalog } from "@geo/shared-types"; import { aiPlatformCatalog } from "@geo/shared-types";
import { computed, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@@ -504,25 +504,72 @@ const visibleCitedArticles = computed(() => {
return citationSummaryQuery.data.value?.cited_articles ?? []; return citationSummaryQuery.data.value?.cited_articles ?? [];
}); });
const citationChartStyle = computed(() => { const citationPieSlices = computed(() => {
const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0); const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
if (!segments.length) { if (!segments.length) {
return { return [];
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
};
} }
let offset = 0; const cx = 100;
const gradientStops = segments.map((item) => { const cy = 100;
const next = offset + item.share * 360; const r = 100;
const stop = `${item.color} ${offset.toFixed(2)}deg ${next.toFixed(2)}deg`;
offset = next;
return stop;
});
let offset = 0;
return segments.map((item) => {
const share = item.share;
if (share === 1) {
return {
...item,
isFull: true,
path: "",
};
}
const startAngle = offset * 2 * Math.PI - Math.PI / 2;
const endAngle = (offset + share) * 2 * Math.PI - Math.PI / 2;
const x1 = cx + r * Math.cos(startAngle);
const y1 = cy + r * Math.sin(startAngle);
const x2 = cx + r * Math.cos(endAngle);
const y2 = cy + r * Math.sin(endAngle);
const largeArcFlag = share > 0.5 ? 1 : 0;
const path = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`;
offset += share;
return {
...item,
isFull: false,
path,
};
});
});
const hoveredSlice = ref<string | null>(null);
const tooltipPos = ref({ x: 0, y: 0 });
function onPieMouseMove(e: MouseEvent) {
const target = e.currentTarget as HTMLElement;
if (!target) return;
const rect = target.getBoundingClientRect();
tooltipPos.value = {
x: e.clientX - rect.left + 15,
y: e.clientY - rect.top + 15
};
}
const hoveredSliceInfo = computed(() => {
if (!hoveredSlice.value) return null;
return citationPieSlices.value.find(s => s.ai_platform_id === hoveredSlice.value) ?? null;
});
const citationChartStyle = computed(() => {
return { return {
background: `conic-gradient(${gradientStops.join(", ")})`, background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
}; };
}); });
@@ -927,8 +974,38 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
<div class="citation-ranking-layout"> <div class="citation-ranking-layout">
<div class="citation-ranking-visual"> <div class="citation-ranking-visual">
<div class="citation-ranking-visual__canvas"> <div class="citation-ranking-visual__canvas" @mouseleave="hoveredSlice = null" @mousemove="onPieMouseMove">
<div class="citation-ranking-visual__circle" :style="citationChartStyle" /> <svg v-if="citationPieSlices.length" viewBox="0 0 200 200" class="citation-pie-svg">
<template v-for="slice in citationPieSlices" :key="slice.ai_platform_id">
<circle
v-if="slice.isFull"
cx="100" cy="100" r="100"
:fill="slice.color"
class="citation-pie-slice"
:class="{ 'is-hovered': hoveredSlice === slice.ai_platform_id }"
@mouseenter="hoveredSlice = slice.ai_platform_id"
/>
<path
v-else
:d="slice.path"
:fill="slice.color"
class="citation-pie-slice"
:class="{ 'is-hovered': hoveredSlice === slice.ai_platform_id, 'is-dimmed': hoveredSlice && hoveredSlice !== slice.ai_platform_id }"
@mouseenter="hoveredSlice = slice.ai_platform_id"
/>
</template>
</svg>
<div v-else class="citation-ranking-visual__circle" :style="citationChartStyle" />
<div
v-if="hoveredSliceInfo"
class="citation-pie-tooltip"
:style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }"
>
<div class="citation-pie-tooltip__marker" :style="{ background: hoveredSliceInfo.color }"></div>
<span class="citation-pie-tooltip__label">{{ hoveredSliceInfo.platform_name }}</span>
<strong class="citation-pie-tooltip__value">{{ formatPercent(hoveredSliceInfo.share) }}</strong>
</div>
</div> </div>
<div v-if="citationRankingRows.length" class="citation-ranking-legend"> <div v-if="citationRankingRows.length" class="citation-ranking-legend">
@@ -1399,12 +1476,74 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
} }
.citation-ranking-visual__canvas { .citation-ranking-visual__canvas {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
.citation-pie-svg {
width: min(32vw, 420px);
height: min(32vw, 420px);
min-width: 280px;
min-height: 280px;
border-radius: 999px;
overflow: visible;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.05));
}
.citation-pie-slice {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s;
transform-origin: 100px 100px;
cursor: pointer;
stroke: #ffffff;
stroke-width: 1.5px;
stroke-linejoin: round;
}
.citation-pie-slice.is-hovered {
transform: scale(1.05);
z-index: 2;
}
.citation-pie-slice.is-dimmed {
opacity: 0.4;
}
.citation-pie-tooltip {
position: absolute;
pointer-events: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(4px);
border: 1px solid #e5e7eb;
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
font-size: 13px;
white-space: nowrap;
transition: opacity 0.2s;
}
.citation-pie-tooltip__marker {
width: 8px;
height: 8px;
border-radius: 50%;
}
.citation-pie-tooltip__label {
color: #4b5563;
}
.citation-pie-tooltip__value {
color: #111827;
font-weight: 600;
}
.citation-ranking-visual__circle { .citation-ranking-visual__circle {
width: min(32vw, 420px); width: min(32vw, 420px);
height: min(32vw, 420px); height: min(32vw, 420px);