refactor(admin-web): render citation pie as interactive SVG with hover tooltip
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:
@@ -13,7 +13,7 @@ import type {
|
||||
MonitoringPlatformAuthorizationStatus,
|
||||
} 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 { useRoute, useRouter } from "vue-router";
|
||||
|
||||
@@ -504,25 +504,72 @@ const visibleCitedArticles = computed(() => {
|
||||
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);
|
||||
|
||||
if (!segments.length) {
|
||||
return {
|
||||
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
|
||||
};
|
||||
return [];
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const gradientStops = segments.map((item) => {
|
||||
const next = offset + item.share * 360;
|
||||
const stop = `${item.color} ${offset.toFixed(2)}deg ${next.toFixed(2)}deg`;
|
||||
offset = next;
|
||||
return stop;
|
||||
});
|
||||
const cx = 100;
|
||||
const cy = 100;
|
||||
const r = 100;
|
||||
|
||||
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 {
|
||||
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-visual">
|
||||
<div class="citation-ranking-visual__canvas">
|
||||
<div class="citation-ranking-visual__circle" :style="citationChartStyle" />
|
||||
<div class="citation-ranking-visual__canvas" @mouseleave="hoveredSlice = null" @mousemove="onPieMouseMove">
|
||||
<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 v-if="citationRankingRows.length" class="citation-ranking-legend">
|
||||
@@ -1399,12 +1476,74 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
.citation-ranking-visual__canvas {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
width: min(32vw, 420px);
|
||||
height: min(32vw, 420px);
|
||||
|
||||
Reference in New Issue
Block a user