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,
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user