feat: 综合分析大屏功能新增。

This commit is contained in:
tianyongbao
2026-06-18 22:49:26 +08:00
parent 0b0dd5c8e8
commit 90e923f490
2 changed files with 2372 additions and 112 deletions

View File

@@ -70,24 +70,53 @@
</div>
</div>
<div class="insight-panel">
<div class="insight-card">
<span>热点占比</span>
<strong>{{ formatPercent(topPointShare) }}</strong>
<em>{{ topPoint?.locationName || '暂无热点' }}</em>
</div>
<div class="insight-card">
<span>点均消费</span>
<strong>{{ formatMoney(averageLocationAmount) }}</strong>
<em>按定位地点均摊</em>
</div>
<div class="insight-card">
<span>当前点位占比</span>
<strong>{{ formatPercent(selectedPointShare) }}</strong>
<em>{{ selectedPoint?.locationName || '未选择点位' }}</em>
</div>
<div class="insight-card">
<span>最近交易</span>
<strong>{{ latestRecordTime }}</strong>
<em>{{ latestRecordName }}</em>
</div>
</div>
<div class="side-panel side-panel-left">
<div class="panel-title">地点消费排行</div>
<div v-if="locationPoints.length" class="rank-list">
<button
v-for="(item, index) in locationPoints.slice(0, 12)"
v-for="(item, index) in rankItems"
:key="item.locationKey"
class="rank-item"
:class="{ active: selectedPoint && selectedPoint.locationKey === item.locationKey }"
:style="{ '--rank-color': getPointColor(Number(item.totalAmount || 0)), '--rank-percent': `${rankPercent(item)}%` }"
@click="selectPoint(item)"
>
<span class="rank-main">
<span class="rank-index">{{ index + 1 }}</span>
<span class="rank-info">
<span class="rank-name">{{ item.locationName || '未命名地点' }}</span>
<span class="rank-address">{{ item.locationAddress || `${item.longitude}, ${item.latitude}` }}</span>
<span class="rank-address">{{ item.locationAddress || '暂无地址' }}</span>
</span>
<span class="rank-value">
<strong>{{ formatMoney(item.totalAmount) }}</strong>
<em>{{ item.dealCount || 0 }}</em>
<em>{{ item.dealCount || 0 }} · {{ formatPercent(getAmountRatio(item.totalAmount)) }}</em>
</span>
</span>
<span class="rank-progress">
<i></i>
</span>
</button>
</div>
@@ -97,23 +126,19 @@
<div class="side-panel side-panel-right">
<div class="panel-title">
<span>点位明细</span>
<small v-if="selectedPoint">{{ selectedPoint.dealCount || 0 }}笔交易</small>
</div>
<div v-if="selectedPoint" class="selected-info">
<div class="selected-main">
<div class="selected-copy">
<span class="selected-kicker">当前点位</span>
<strong>{{ selectedPoint.locationName || '未命名地点' }}</strong>
<em>{{ selectedPoint.locationAddress || '暂无地址' }}</em>
</div>
<div class="selected-amount">
<span>累计消费</span>
<strong>{{ formatMoney(selectedPoint.totalAmount) }}</strong>
</div>
</div>
<div class="selected-address">
<span>地址</span>
<strong>{{ selectedPoint.locationAddress || '暂无地址' }}</strong>
</div>
<div class="selected-stat-grid">
<div>
<span>交易笔数</span>
@@ -123,13 +148,45 @@
<span>单笔均值</span>
<strong>{{ formatMoney(getAverageAmount(selectedPoint)) }}</strong>
</div>
<div class="selected-stat-wide">
<span>经纬度</span>
<strong>{{ formatCoordinate(selectedPoint) }}</strong>
</div>
</div>
<div v-if="selectedPoint && recordLoading" class="record-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在加载点位流水</span>
</div>
<div v-if="selectedPoint && !recordLoading && hasPointStats" class="point-breakdown">
<div class="breakdown-column">
<div class="breakdown-title">
<span>子类构成</span>
<em>{{ pointCategoryStats.length }}</em>
</div>
<div v-for="item in pointCategoryStats" :key="item.label" class="breakdown-item">
<div class="breakdown-line">
<span>{{ item.label }}</span>
<strong>{{ formatMoney(item.amount) }}</strong>
</div>
<div class="breakdown-track">
<i :style="{ width: `${item.percent}%` }"></i>
</div>
</div>
</div>
<div v-if="recordList.length" class="detail-section">
<div class="breakdown-column">
<div class="breakdown-title">
<span>账户分布</span>
<em>{{ pointAccountStats.length }}</em>
</div>
<div v-for="item in pointAccountStats" :key="item.label" class="breakdown-item">
<div class="breakdown-line">
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</div>
<div class="breakdown-track accent">
<i :style="{ width: `${item.percent}%` }"></i>
</div>
</div>
</div>
</div>
<div v-if="recordList.length && !recordLoading" class="detail-section">
<div class="detail-section-head">
<span>交易流水</span>
<em>最新 {{ recordList.length }} </em>
@@ -153,7 +210,8 @@
</div>
</div>
</div>
<el-empty v-else description="点击地图点查看明细" :image-size="80" />
<el-empty v-else-if="!selectedPoint" description="点击地图点查看明细" :image-size="80" />
<el-empty v-else-if="!recordLoading" description="该点位暂无交易流水" :image-size="80" />
</div>
<div v-if="loading" class="screen-loading">
@@ -165,10 +223,11 @@
<script setup name="AccountDealLocationScreen">
import dayjs from 'dayjs'
import { PointLayer, PolygonLayer, Popup, RasterLayer } from '@antv/l7'
import { PointLayer, Popup, RasterLayer, Scene } from '@antv/l7'
import { RasterTileType } from '@antv/l7-core'
import { Map as L7Map } from '@antv/l7-maps'
import { listAccounts } from '@/api/invest/accounts'
import { mapConfig, layerUrl } from '@/config/map'
import { initMap } from '@/utils/map'
import { layerUrl, mapConfig } from '@/config/map'
import { getAccountDealLocationOverview, getAccountDealLocationPoints, getAccountDealLocationRecords } from '@/api/invest/statisticAnalysis'
const { proxy } = getCurrentInstance()
@@ -179,11 +238,11 @@ const scene = ref(null)
const pointLayers = ref([])
const popup = ref(null)
const mapReady = ref(false)
let darkBaseLayerReady = false
const accountList = ref([])
const locationPoints = ref([])
const recordList = ref([])
const selectedPoint = ref(null)
const recordLoading = ref(false)
const overview = ref({
totalAmount: 0,
dealCount: 0,
@@ -200,29 +259,42 @@ const queryParams = ref({
childCategory: null
})
const darkMapMaskData = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
name: 'dark-map-mask'
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-180, -85],
[180, -85],
[180, 85],
[-180, 85],
[-180, -85]
]
]
}
}
]
}
let darkBaseLayerReady = false
const darkTileCache = new globalThis.Map()
const darkTileCacheLimit = 240
const darkTileFilter = 'invert(1) hue-rotate(180deg) brightness(0.52) contrast(0.92) saturate(0.62)'
const darkLabelFilter = 'invert(1) hue-rotate(180deg) brightness(1.18) contrast(1.08) saturate(0.72)'
const mapFocusZoom = 14
const sortedLocationPoints = computed(() => [...locationPoints.value].sort((first, second) => Number(second.totalAmount || 0) - Number(first.totalAmount || 0)))
const topPoint = computed(() => sortedLocationPoints.value[0] || null)
const rankItems = computed(() => sortedLocationPoints.value.slice(0, 12))
const totalAmount = computed(() => Number(overview.value.totalAmount || 0))
const averageLocationAmount = computed(() => {
const count = Number(overview.value.locationCount || locationPoints.value.length || 0)
if (!count) return 0
return totalAmount.value / count
})
const topPointShare = computed(() => getAmountRatio(topPoint.value?.totalAmount))
const selectedPointShare = computed(() => getAmountRatio(selectedPoint.value?.totalAmount))
const latestRecord = computed(() => {
const validRecords = recordList.value
.map((record) => ({ ...record, recordTime: dayjs(record.createTime) }))
.filter((record) => record.recordTime.isValid())
.sort((first, second) => second.recordTime.valueOf() - first.recordTime.valueOf())
return validRecords[0] || null
})
const latestRecordTime = computed(() => {
if (!latestRecord.value) return '--'
return latestRecord.value.recordTime.format('MM-DD HH:mm')
})
const latestRecordName = computed(() => {
if (!latestRecord.value) return '选择点位后查看'
return formatChildCategory(latestRecord.value)
})
const pointCategoryStats = computed(() => buildAmountStats(recordList.value, (record) => formatChildCategory(record), 4))
const pointAccountStats = computed(() => buildCountStats(recordList.value, (record) => record.accountName || '未知账户', 4))
const hasPointStats = computed(() => pointCategoryStats.value.length || pointAccountStats.value.length)
function buildQuery(extra = {}) {
const params = {
@@ -247,6 +319,59 @@ function formatMoney(value) {
})
}
function formatPercent(value) {
const percent = Number(value || 0)
if (!Number.isFinite(percent)) return '0%'
return `${percent.toFixed(percent >= 10 ? 0 : 1)}%`
}
function getAmountRatio(value) {
if (!totalAmount.value) return 0
return (Number(value || 0) / totalAmount.value) * 100
}
function rankPercent(item) {
if (!Number(overview.value.maxAmount || 0)) return 0
return Math.max(4, Math.min(100, (Number(item?.totalAmount || 0) / Number(overview.value.maxAmount || 1)) * 100))
}
function buildAmountStats(records, getLabel, limit = 4) {
const statMap = records.reduce((map, record) => {
const label = getLabel(record)
const current = map.get(label) || { label, amount: 0, count: 0 }
current.amount += Math.abs(Number(record.amount || 0))
current.count += 1
map.set(label, current)
return map
}, new Map())
const stats = Array.from(statMap.values())
.sort((first, second) => second.amount - first.amount)
.slice(0, limit)
const maxAmount = Math.max(...stats.map((item) => item.amount), 0)
return stats.map((item) => ({
...item,
percent: maxAmount ? Math.max(6, (item.amount / maxAmount) * 100) : 0
}))
}
function buildCountStats(records, getLabel, limit = 4) {
const statMap = records.reduce((map, record) => {
const label = getLabel(record)
const current = map.get(label) || { label, count: 0 }
current.count += 1
map.set(label, current)
return map
}, new Map())
const stats = Array.from(statMap.values())
.sort((first, second) => second.count - first.count)
.slice(0, limit)
const maxCount = Math.max(...stats.map((item) => item.count), 0)
return stats.map((item) => ({
...item,
percent: maxCount ? Math.max(6, (item.count / maxCount) * 100) : 0
}))
}
function formatParentCategory(record) {
return record.dealCategoryName || record.dealCategory || '未分类'
}
@@ -360,23 +485,41 @@ async function loadScreenData() {
recordList.value = []
selectedPoint.value = null
clearPopup()
renderPointLayers()
renderPointLayers({ fitFirst: false })
if (topPoint.value) {
await selectPoint(topPoint.value)
}
} finally {
loading.value = false
}
}
async function selectPoint(point) {
async function selectPoint(point, options = {}) {
selectedPoint.value = point
recordLoading.value = true
renderPointLayers({ fitFirst: false })
scene.value?.setCenter([Number(point.longitude), Number(point.latitude)])
if (options.focus !== false) {
focusPointOnMap(point, options.zoom || mapFocusZoom)
}
const params = buildQuery({
longitude: point.longitude,
latitude: point.latitude
})
try {
const response = await getAccountDealLocationRecords(params)
recordList.value = response.data || []
showPointPopup(point)
} finally {
recordLoading.value = false
}
}
function focusPointOnMap(point, zoom = mapFocusZoom) {
const longitude = Number(point?.longitude)
const latitude = Number(point?.latitude)
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return
scene.value?.setCenter([longitude, latitude])
scene.value?.setZoom(zoom)
}
function getPointColor(amount) {
@@ -537,10 +680,90 @@ function renderPointLayers(options = {}) {
})
}
if (options.fitFirst !== false) {
const firstPoint = data[0]
scene.value.setCenter([Number(firstPoint.longitude), Number(firstPoint.latitude)])
scene.value.setZoom(12)
if (options.fitFirst !== false && topPoint.value) {
focusPointOnMap(topPoint.value)
}
}
function fillTileUrl(template, tile) {
return template.replace(/\{x\}/gi, tile.x).replace(/\{y\}/gi, tile.y).replace(/\{z\}/gi, tile.z).replace(/\{l\}/gi, tile.z)
}
function rememberDarkTile(cacheKey, buffer) {
if (darkTileCache.size >= darkTileCacheLimit) {
const firstKey = darkTileCache.keys().next().value
darkTileCache.delete(firstKey)
}
darkTileCache.set(cacheKey, buffer)
}
function blobToImage(blob) {
if (window.createImageBitmap) {
return window.createImageBitmap(blob)
}
return new Promise((resolve, reject) => {
const image = new Image()
const objectUrl = URL.createObjectURL(blob)
image.onload = () => {
URL.revokeObjectURL(objectUrl)
resolve(image)
}
image.onerror = (event) => {
URL.revokeObjectURL(objectUrl)
reject(event)
}
image.src = objectUrl
})
}
function canvasToArrayBuffer(canvas) {
if (canvas.convertToBlob) {
return canvas.convertToBlob({ type: 'image/png' }).then((blob) => blob.arrayBuffer())
}
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('地图瓦片暗色转换失败'))
return
}
blob.arrayBuffer().then(resolve).catch(reject)
}, 'image/png')
})
}
function createDarkTileGetter(template, filter) {
return async (tile, callback) => {
const tileUrl = fillTileUrl(template, tile)
const cacheKey = `${tileUrl}|${filter}`
const cachedTile = darkTileCache.get(cacheKey)
if (cachedTile) {
callback(null, cachedTile)
return
}
try {
const response = await fetch(tileUrl)
if (!response.ok) {
throw new Error(`地图瓦片加载失败:${response.status}`)
}
const image = await blobToImage(await response.blob())
const canvas = document.createElement('canvas')
const width = image.width || 256
const height = image.height || 256
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
context.filter = filter
context.drawImage(image, 0, 0, width, height)
context.filter = 'none'
const buffer = await canvasToArrayBuffer(canvas)
rememberDarkTile(cacheKey, buffer)
callback(null, buffer)
} catch (error) {
callback(error)
}
}
}
@@ -551,31 +774,21 @@ function initDarkBaseLayer(mapScene) {
layerUrl.forEach((item, index) => {
const layer = new RasterLayer({
zIndex: index,
name: item.name
name: `accountDealLocationDark${item.name || index}`
})
.source(item.url, {
.source('account-deal-location-dark-tile', {
parser: {
type: 'rasterTile',
tileSize: 256
dataType: RasterTileType.CUSTOMIMAGE,
tileSize: 256,
getCustomData: createDarkTileGetter(item.url, item.name === 'title' ? darkLabelFilter : darkTileFilter)
}
})
.style({
opacity: item.name === 'title' ? 0.82 : 0.68
opacity: item.name === 'title' ? 0.86 : 1
})
mapScene.addLayer(layer)
})
const maskLayer = new PolygonLayer({
zIndex: 8,
name: 'accountDealLocationDarkMask'
})
.source(darkMapMaskData)
.shape('fill')
.color('#03111f')
.style({
opacity: 0.64
})
mapScene.addLayer(maskLayer)
}
function clearPopup() {
@@ -616,10 +829,17 @@ function showPointPopup(point) {
}
async function initScreenMap() {
scene.value = await initMap('accountDealLocationMap', {
scene.value = new Scene({
id: 'accountDealLocationMap',
logoVisible: false,
map: new L7Map({
...mapConfig,
zoom: 11
zoom: 11,
pitch: 0,
logoVisible: false
})
})
scene.value.on('loaded', () => {
mapReady.value = true
initDarkBaseLayer(scene.value)
@@ -640,6 +860,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
clearPointLayers()
darkBaseLayerReady = false
if (scene.value) {
scene.value.destroy()
scene.value = null
@@ -810,7 +1031,18 @@ onBeforeUnmount(() => {
margin: 14px auto 0;
}
.insight-panel {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 8px;
width: min(760px, calc(100vw - 36px));
margin: 10px auto 0;
}
.summary-card,
.insight-card,
.side-panel {
background: rgba(8, 20, 32, 0.86);
border: 1px solid rgba(148, 163, 184, 0.22);
@@ -836,9 +1068,55 @@ onBeforeUnmount(() => {
color: #f8fafc;
}
.insight-card {
position: relative;
overflow: hidden;
min-height: 58px;
padding: 10px 12px;
border-color: rgba(103, 232, 249, 0.18);
border-radius: 6px;
}
.insight-card::before {
position: absolute;
left: 0;
top: 10px;
bottom: 10px;
width: 2px;
content: '';
background: linear-gradient(180deg, rgba(103, 232, 249, 0.2), rgba(45, 212, 191, 0.95), rgba(103, 232, 249, 0.2));
}
.insight-card span,
.insight-card em {
display: block;
overflow: hidden;
color: rgba(186, 230, 253, 0.62);
font-size: 12px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.insight-card strong {
display: block;
margin-top: 4px;
overflow: hidden;
color: #e0faff;
font-size: 18px;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.insight-card em {
margin-top: 5px;
font-style: normal;
}
.side-panel {
position: absolute;
top: 178px;
top: 92px;
bottom: 18px;
width: 360px;
padding: 16px;
@@ -890,12 +1168,12 @@ onBeforeUnmount(() => {
}
.rank-item {
display: grid;
grid-template-columns: 28px 1fr 92px;
gap: 10px;
align-items: center;
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-height: 62px;
min-height: 70px;
padding: 10px;
color: #f8fafc;
text-align: left;
@@ -911,6 +1189,14 @@ onBeforeUnmount(() => {
background: rgba(15, 118, 110, 0.3);
}
.rank-main {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 104px;
gap: 10px;
align-items: flex-start;
min-width: 0;
}
.rank-index {
display: inline-flex;
align-items: center;
@@ -919,8 +1205,9 @@ onBeforeUnmount(() => {
height: 26px;
color: #06121e;
font-weight: 700;
background: #67e8f9;
background: var(--rank-color, #67e8f9);
border-radius: 50%;
box-shadow: 0 0 14px color-mix(in srgb, var(--rank-color, #67e8f9) 42%, transparent);
}
.rank-info {
@@ -930,9 +1217,10 @@ onBeforeUnmount(() => {
.rank-name,
.rank-address {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-all;
}
.rank-name {
@@ -965,6 +1253,24 @@ onBeforeUnmount(() => {
font-size: 12px;
}
.rank-progress {
display: block;
width: 100%;
height: 4px;
overflow: hidden;
background: rgba(148, 163, 184, 0.12);
border-radius: 999px;
}
.rank-progress i {
display: block;
width: var(--rank-percent, 0%);
height: 100%;
background: linear-gradient(90deg, var(--rank-color, #67e8f9), rgba(224, 250, 255, 0.9));
border-radius: inherit;
box-shadow: 0 0 12px color-mix(in srgb, var(--rank-color, #67e8f9) 55%, transparent);
}
.selected-info {
position: relative;
flex: 0 0 auto;
@@ -1002,7 +1308,6 @@ onBeforeUnmount(() => {
.selected-kicker,
.selected-amount span,
.selected-address span,
.selected-stat-grid span {
display: block;
color: rgba(186, 230, 253, 0.58);
@@ -1013,13 +1318,25 @@ onBeforeUnmount(() => {
.selected-copy strong {
display: block;
margin-top: 5px;
overflow: hidden;
color: #f8fafc;
font-size: 15px;
font-weight: 700;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-all;
}
.selected-copy em {
display: block;
margin-top: 4px;
color: rgba(226, 232, 240, 0.62);
font-size: 12px;
font-style: normal;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-all;
}
.selected-amount {
@@ -1042,26 +1359,6 @@ onBeforeUnmount(() => {
text-shadow: 0 0 14px rgba(103, 232, 249, 0.34);
}
.selected-address {
margin-top: 8px;
padding: 7px 8px;
background: rgba(15, 23, 42, 0.44);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 4px;
}
.selected-address strong {
display: block;
margin-top: 5px;
overflow: hidden;
color: rgba(226, 232, 240, 0.8);
font-size: 12px;
font-weight: 500;
line-height: 1.55;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1092,6 +1389,104 @@ onBeforeUnmount(() => {
grid-column: 1 / -1;
}
.record-loading {
flex: 1 1 auto;
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: rgba(224, 250, 255, 0.78);
font-size: 13px;
}
.point-breakdown {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.breakdown-column {
min-width: 0;
padding: 10px;
background: rgba(3, 15, 28, 0.54);
border: 1px solid rgba(125, 211, 252, 0.12);
border-radius: 6px;
}
.breakdown-title,
.breakdown-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.breakdown-title {
margin-bottom: 8px;
color: rgba(224, 250, 255, 0.88);
font-size: 13px;
font-weight: 700;
}
.breakdown-title em {
flex: 0 0 auto;
color: rgba(186, 230, 253, 0.52);
font-size: 12px;
font-style: normal;
font-weight: 400;
}
.breakdown-item + .breakdown-item {
margin-top: 8px;
}
.breakdown-line span,
.breakdown-line strong {
overflow: hidden;
font-size: 12px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.breakdown-line span {
min-width: 0;
color: rgba(226, 232, 240, 0.7);
}
.breakdown-line strong {
flex: 0 0 auto;
max-width: 76px;
color: #e0faff;
font-weight: 700;
text-align: right;
}
.breakdown-track {
height: 4px;
margin-top: 5px;
overflow: hidden;
background: rgba(148, 163, 184, 0.12);
border-radius: 999px;
}
.breakdown-track i {
display: block;
height: 100%;
background: linear-gradient(90deg, #67e8f9, #22d3ee);
border-radius: inherit;
box-shadow: 0 0 10px rgba(34, 211, 238, 0.32);
}
.breakdown-track.accent i {
background: linear-gradient(90deg, #2dd4bf, #bef264);
box-shadow: 0 0 10px rgba(45, 212, 191, 0.28);
}
.detail-section {
flex: 1 1 auto;
min-height: 0;
@@ -1373,11 +1768,12 @@ onBeforeUnmount(() => {
:global(.location-popup-title) {
display: block;
overflow: hidden;
color: #ffffff;
font-size: 16px;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-all;
}
:global(.location-popup-metrics) {
@@ -1427,12 +1823,18 @@ onBeforeUnmount(() => {
}
.side-panel {
top: 306px;
width: 320px;
}
.point-breakdown {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.summary-panel {
.summary-panel,
.insight-panel {
grid-template-columns: repeat(2, minmax(130px, 1fr));
}

File diff suppressed because it is too large Load Diff