349 lines
9.9 KiB
Vue
349 lines
9.9 KiB
Vue
<script setup>
|
|
import SectionWrapper from './SectionWrapper.vue';
|
|
import { computed, ref } from 'vue';
|
|
|
|
const props = defineProps({ id: String, data: [Array, Object] });
|
|
|
|
const normalizedData = computed(() => {
|
|
const arr = Array.isArray(props.data) ? props.data : (props.data?.competitors || []);
|
|
return arr.map(c => ({
|
|
...c,
|
|
erVal: parseFloat(c.engagementRate ?? c.er) || 0,
|
|
ratingVal: parseFloat(c.averageRating ?? c.rating) || 0,
|
|
followersVal: parseInt(c.followers) || 0,
|
|
postsVal: parseInt(c.postsPerMonth) || 0
|
|
}));
|
|
});
|
|
|
|
const expanded = ref(null);
|
|
const toggleRow = (name) => { expanded.value = expanded.value === name ? null : name; };
|
|
|
|
const fmtNum = (n) => n != null ? Number(n).toLocaleString('ru-RU') : '—';
|
|
const fmtPct = (n) => n != null ? parseFloat(n).toFixed(2) + '%' : '—';
|
|
const erColor = (er) => parseFloat(er) >= 6 ? 'var(--kai-green)' : parseFloat(er) >= 3 ? 'var(--kai-yellow)' : 'var(--kai-red)';
|
|
const PLAT_COLORS = { Instagram:'#E1306C', TikTok:'#69C9D0', Telegram:'#2AABEE', '2GIS':'#00B140', YouTube:'#FF0000' };
|
|
const platColor = (p) => PLAT_COLORS[p] || '#808080';
|
|
|
|
const sorted = computed(() => [...normalizedData.value].sort((a, b) => b.followersVal - a.followersVal));
|
|
|
|
// Scatter data
|
|
const scatter = computed(() => normalizedData.value.map((c, i) => ({
|
|
x: c.postsVal || 0,
|
|
y: c.erVal || 0,
|
|
r: Math.max(Math.sqrt((c.followersVal || 0) / 1000) * 5, 4),
|
|
name: c.name,
|
|
color: platColor(c.platform)
|
|
})));
|
|
const maxX = computed(() => Math.max(Math.max(...scatter.value.map(d => d.x)) * 1.1, 10));
|
|
const maxY = computed(() => Math.max(Math.max(...scatter.value.map(d => d.y)) * 1.2, 5));
|
|
</script>
|
|
|
|
<template>
|
|
<SectionWrapper :id="id" title="Карта конкурентов" subtitle="Позиционирование игроков: Активность vs Вовлечённость" num="3">
|
|
|
|
<!-- Scatter plot -->
|
|
<div class="competitor-plot-card">
|
|
<div class="plot-header">
|
|
<span class="plot-axis-label">Активность (посты/мес) ➔</span>
|
|
<span class="plot-axis-label vertical">Вовлечённость (ER %) ↑</span>
|
|
<i class="pi pi-info-circle kai-term-icon" v-tooltip.right="'Лидеры (справа вверху) делают много постов и получают высокий отклик.'"></i>
|
|
</div>
|
|
|
|
<div class="plot-leader-zone">ЛИДЕРЫ</div>
|
|
|
|
<div class="plot-container">
|
|
<svg class="plot-svg" :viewBox="`0 0 500 220`" preserveAspectRatio="none">
|
|
<!-- Grid -->
|
|
<line x1="50" y1="10" x2="50" y2="200" class="plot-grid-line"/>
|
|
<line x1="50" y1="200" x2="490" y2="200" class="plot-grid-line"/>
|
|
|
|
<!-- Leader quadrant -->
|
|
<rect x="270" y="10" width="220" height="100" class="plot-leader-rect" rx="8"/>
|
|
|
|
<!-- Data points -->
|
|
<g v-for="(pt, i) in scatter" :key="i" class="plot-point-group">
|
|
<circle
|
|
:cx="50 + (pt.x / maxX) * 440"
|
|
:cy="195 - (pt.y / maxY) * 185"
|
|
:r="Math.max(pt.r, 5)"
|
|
:fill="pt.color"
|
|
class="plot-point"
|
|
/>
|
|
<text
|
|
:x="50 + (pt.x / maxX) * 440"
|
|
:y="195 - (pt.y / maxY) * 185 - Math.max(pt.r, 5) - 6"
|
|
class="plot-point-text"
|
|
>{{ pt.name }}</text>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div class="table-container">
|
|
<table class="kai-table">
|
|
<thead>
|
|
<tr>
|
|
<th width="40">#</th>
|
|
<th>Бренд</th>
|
|
<th>Платформа</th>
|
|
<th>Подписчики</th>
|
|
<th>ER</th>
|
|
<th>★ Рейтинг</th>
|
|
<th>Контент</th>
|
|
<th width="40"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="(c, i) in sorted" :key="c.name">
|
|
<tr :class="{ 'top-row': i < 3 }" :style="i < 3 ? { '--top-color': ['#F59E0B','#939393','#CD7F32'][i] } : {}">
|
|
<td class="row-num">{{ String(i+1).padStart(2,'0') }}</td>
|
|
<td>
|
|
<div class="brand-cell">
|
|
<div class="brand-avatar" :style="{ background: platColor(c.platform) + '20', color: platColor(c.platform), borderColor: platColor(c.platform) + '40' }">
|
|
<i class="pi pi-building text-[11px]" />
|
|
</div>
|
|
<span class="brand-name">{{ c.name }}</span>
|
|
</div>
|
|
</td>
|
|
<td><span class="kai-plat-badge" :style="{ background: platColor(c.platform)+'20', color: platColor(c.platform), border: `1px solid ${platColor(c.platform)}20` }">{{ c.platform }}</span></td>
|
|
<td class="font-bold">{{ fmtNum(c.followersVal) }}</td>
|
|
<td class="font-bold" :style="{ color: erColor(c.erVal) }">{{ fmtPct(c.erVal) }}</td>
|
|
<td class="font-bold text-yellow-500">★ {{ (c.ratingVal).toFixed(1) }}</td>
|
|
<td class="font-bold">{{ c.postsVal }} <small class="text-surface-500 ml-1">/ мес</small></td>
|
|
<td>
|
|
<button class="expand-btn" :class="{ active: expanded === c.name }" @click="toggleRow(c.name)">
|
|
<i class="pi pi-chevron-down" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Expanded content -->
|
|
<tr v-if="expanded === c.name" class="expanded-row">
|
|
<td colspan="8">
|
|
<div class="expand-content">
|
|
<div class="sw-grid">
|
|
<div class="sw-col">
|
|
<div class="sw-title green">Сильные стороны</div>
|
|
<ul class="sw-list">
|
|
<li v-for="(s, si) in (c.strengths||[])" :key="si">{{ s }}</li>
|
|
<li v-if="!c.strengths?.length" class="empty-sw">Нет данных</li>
|
|
</ul>
|
|
</div>
|
|
<div class="sw-col">
|
|
<div class="sw-title red">Слабые стороны</div>
|
|
<ul class="sw-list">
|
|
<li v-for="(w, wi) in (c.weaknesses||[])" :key="wi">{{ w }}</li>
|
|
<li v-if="!c.weaknesses?.length" class="empty-sw">Нет данных</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<tr v-if="!sorted.length"><td colspan="8" class="empty-table">Нет данных</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</SectionWrapper>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.competitor-plot-card {
|
|
background: var(--kai-bg2);
|
|
border: 1px solid var(--kai-border);
|
|
border-radius: 20px;
|
|
padding: 24px;
|
|
margin-bottom: 32px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.plot-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.plot-axis-label {
|
|
font-size: 9.5px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: var(--kai-txt3);
|
|
}
|
|
|
|
.plot-leader-zone {
|
|
position: absolute;
|
|
top: 60px;
|
|
right: 40px;
|
|
font-family: 'Unbounded', sans-serif;
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
color: var(--kai-blue);
|
|
opacity: 0.4;
|
|
letter-spacing: 0.2em;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.plot-container {
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.plot-grid-line {
|
|
stroke: var(--kai-border);
|
|
stroke-width: 1;
|
|
}
|
|
|
|
.plot-leader-rect {
|
|
fill: rgba(var(--kai-blue-rgb), 0.05);
|
|
}
|
|
|
|
.plot-point {
|
|
fill-opacity: 0.8;
|
|
stroke: rgba(255,255,255,0.2);
|
|
stroke-width: 1;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.plot-point-group:hover .plot-point {
|
|
fill-opacity: 1;
|
|
r: 10;
|
|
stroke-width: 2;
|
|
filter: drop-shadow(0 0 8px currentColor);
|
|
}
|
|
|
|
.plot-point-text {
|
|
font-size: 8px;
|
|
font-weight: 700;
|
|
fill: var(--kai-txt2);
|
|
text-anchor: middle;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Table Stylings */
|
|
.table-container {
|
|
border-radius: 16px;
|
|
overflow: hidden;
|
|
background: var(--kai-bg2);
|
|
border: 1px solid var(--kai-border);
|
|
}
|
|
|
|
.top-row {
|
|
background: linear-gradient(to right, rgba(var(--kai-blue-rgb), 0.03), transparent);
|
|
border-left: 3px solid var(--top-color);
|
|
}
|
|
|
|
.row-num {
|
|
font-family: 'Unbounded', sans-serif;
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
color: var(--kai-txt3);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.brand-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.brand-avatar {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 8px;
|
|
border: 1px solid transparent;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.brand-name {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: var(--kai-txt);
|
|
}
|
|
|
|
.expand-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--kai-txt3);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.expand-btn.active {
|
|
transform: rotate(180deg);
|
|
color: var(--kai-blue);
|
|
}
|
|
|
|
.expanded-row td {
|
|
padding: 0 !important;
|
|
background: rgba(45,123,255,0.04);
|
|
border-bottom: 1px solid var(--kai-border);
|
|
}
|
|
|
|
.expand-content {
|
|
padding: 24px 32px;
|
|
}
|
|
|
|
.sw-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 40px;
|
|
}
|
|
|
|
.sw-title {
|
|
font-family: 'Unbounded', sans-serif;
|
|
font-size: 9px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.sw-title.green { color: var(--kai-green); }
|
|
.sw-title.red { color: var(--kai-red); }
|
|
|
|
.sw-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.sw-list li {
|
|
font-size: 11.5px;
|
|
color: var(--kai-txt2);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
|
|
.sw-list li::before {
|
|
content: '→';
|
|
color: var(--kai-txt3);
|
|
font-size: 10px;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.empty-sw {
|
|
color: var(--kai-txt3) !important;
|
|
font-style: italic;
|
|
font-size: 11px !important;
|
|
}
|
|
|
|
.empty-table {
|
|
text-align: center;
|
|
color: var(--kai-txt3);
|
|
padding: 40px;
|
|
font-size: 12px;
|
|
}
|
|
</style>
|