Add DashboardCard component and redesign peak traffic chart

- Create reusable DashboardCard component with title and content slot
- Redesign PeakTrafficChart to match Figma design:
  - Vertical bar chart showing peak hours by day of week
  - Y-axis: hours (0:00 to 21:00)
  - X-axis: days (Mon-Sun)
  - Two data series: "This month" (solid) and "Last month" (hatched)
  - Diagonal stripe pattern for last month bars using SVG
  - Legend at bottom

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-08 08:15:57 +08:00
parent e387d8c123
commit ad65469db6
3 changed files with 128 additions and 65 deletions

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cn } from "$lib/utils.js";
interface Props {
title: string;
children: Snippet;
class?: string;
}
let { title, children, class: className }: Props = $props();
</script>
<div
class={cn(
"overflow-hidden rounded-lg border border-border bg-card shadow-card",
className,
)}
>
<div class="border-b border-border px-5 py-3">
<h3 class="text-sm font-semibold text-foreground">{title}</h3>
</div>
<div class="p-5">
{@render children()}
</div>
</div>

View File

@@ -1,3 +1,4 @@
export { default as DashboardCard } from "./dashboard-card.svelte";
export { default as FrequentFilters } from "./frequent-filters.svelte";
export { default as MetricCard } from "./metric-card.svelte";
export { default as PeakTrafficChart } from "./peak-traffic-chart.svelte";

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import DashboardCard from "./dashboard-card.svelte";
interface Props {
class?: string;
@@ -7,80 +8,102 @@ interface Props {
let { class: className }: Props = $props();
const hours = [
"0:00",
"3:00",
"6:00",
"9:00",
"12:00",
"15:00",
"18:00",
"21:00",
];
const hours = ["0:00", "6:00", "12:00", "15:00", "18:00", "21:00"];
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
// Traffic intensity data (0-100)
const heatmapData = [
[20, 35, 45, 60, 75, 55, 30],
[15, 25, 40, 55, 65, 45, 25],
[25, 40, 55, 70, 80, 60, 35],
[35, 50, 65, 80, 90, 70, 45],
[40, 55, 70, 85, 95, 75, 50],
[45, 60, 75, 90, 85, 70, 45],
[50, 65, 80, 75, 70, 55, 40],
[35, 50, 60, 55, 50, 40, 30],
// Traffic data: [startHour, endHour] for each day (0-24 scale)
// This month data (solid bars)
const thisMonthData = [
{ start: 6, end: 18 },
{ start: 9, end: 15 },
{ start: 12, end: 18 },
{ start: 15, end: 21 },
{ start: 12, end: 18 },
{ start: 3, end: 12 },
{ start: 9, end: 18 },
];
function getCellOpacity(value: number): number {
return 0.15 + (value / 100) * 0.85;
// Last month data (hatched bars)
const lastMonthData = [
{ start: 3, end: 9 },
{ start: 12, end: 18 },
{ start: 6, end: 12 },
{ start: 6, end: 12 },
{ start: 6, end: 12 },
{ start: 9, end: 15 },
{ start: 15, end: 21 },
];
// Convert hour to percentage position (0:00 = 0%, 24:00 = 100%)
function hourToPercent(hour: number): number {
return (hour / 24) * 100;
}
</script>
<div
class={cn(
"overflow-hidden rounded-lg border border-border bg-card shadow-card",
className,
)}
>
<div class="border-b border-border px-5 py-3">
<h3 class="text-sm font-semibold text-foreground">Peak Traffic Hours</h3>
</div>
<div class="p-5">
<div class="flex gap-3">
<DashboardCard title="Peak Traffic Hours" class={className}>
<div class="relative">
<!-- Chart container -->
<div class="flex">
<!-- Y-axis labels -->
<div class="flex flex-col justify-between pr-1">
<div class="flex w-10 flex-col justify-between pr-2" style="height: 210px">
{#each hours as hour}
<div class="flex h-6 items-center">
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
</div>
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
{/each}
</div>
<!-- Grid -->
<div class="flex-1">
<div class="grid grid-cols-7 gap-1">
{#each hours as _, hourIndex}
{#each days as _, dayIndex}
{@const value = heatmapData[hourIndex][dayIndex]}
<!-- Chart area -->
<div class="relative flex-1">
<!-- Grid lines -->
<div class="absolute inset-0 flex flex-col justify-between" style="height: 210px">
{#each hours as _}
<div class="h-px w-full bg-border"></div>
{/each}
</div>
<!-- Bars container -->
<div class="relative grid grid-cols-7 gap-4 px-2" style="height: 210px">
{#each days as _, dayIndex}
{@const thisMonth = thisMonthData[dayIndex]}
{@const lastMonth = lastMonthData[dayIndex]}
<div class="relative">
<!-- This month bar (solid) -->
<div
class="group relative h-6 rounded transition-all duration-150 hover:ring-2 hover:ring-foreground/20"
style="background-color: oklch(0.205 0.006 285 / {getCellOpacity(value)})"
title="{days[dayIndex]} {hours[hourIndex]}: {value}% traffic"
class="absolute left-0 w-3 rounded-sm bg-muted-foreground/60"
style="
top: {hourToPercent(thisMonth.start)}%;
height: {hourToPercent(thisMonth.end - thisMonth.start)}%;
"
></div>
<!-- Last month bar (hatched) -->
<div
class="absolute left-4 w-3 overflow-hidden rounded-sm"
style="
top: {hourToPercent(lastMonth.start)}%;
height: {hourToPercent(lastMonth.end - lastMonth.start)}%;
"
>
<!-- Tooltip -->
<div
class="pointer-events-none absolute -top-8 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap rounded bg-foreground px-2 py-1 text-[10px] font-medium text-background opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
>
{value}%
</div>
<svg class="h-full w-full" preserveAspectRatio="none">
<defs>
<pattern
id="diagonal-{dayIndex}"
patternUnits="userSpaceOnUse"
width="4"
height="4"
patternTransform="rotate(45)"
>
<line x1="0" y1="0" x2="0" y2="4" stroke="currentColor" stroke-width="1.5" class="text-muted-foreground/50" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#diagonal-{dayIndex})" />
</svg>
</div>
{/each}
</div>
{/each}
</div>
<!-- X-axis labels -->
<div class="mt-2 grid grid-cols-7 gap-1">
<div class="mt-2 grid grid-cols-7 gap-4 px-2">
{#each days as day}
<div class="text-center text-[11px] text-muted-foreground">{day}</div>
{/each}
@@ -89,18 +112,30 @@ function getCellOpacity(value: number): number {
</div>
<!-- Legend -->
<div class="mt-5 flex items-center justify-center gap-4">
<div class="mt-4 flex items-center justify-center gap-6">
<div class="flex items-center gap-2">
<div class="flex items-center gap-0.5">
{#each [0.2, 0.4, 0.6, 0.8, 1] as opacity}
<div
class="h-2 w-4 first:rounded-l last:rounded-r"
style="background-color: oklch(0.205 0.006 285 / {opacity})"
></div>
{/each}
<div class="h-3 w-3 rounded-sm bg-muted-foreground/60"></div>
<span class="text-[11px] text-muted-foreground">This month</span>
</div>
<div class="flex items-center gap-2">
<div class="relative h-3 w-3 overflow-hidden rounded-sm">
<svg class="h-full w-full" preserveAspectRatio="none">
<defs>
<pattern
id="diagonal-legend"
patternUnits="userSpaceOnUse"
width="4"
height="4"
patternTransform="rotate(45)"
>
<line x1="0" y1="0" x2="0" y2="4" stroke="currentColor" stroke-width="1.5" class="text-muted-foreground/50" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#diagonal-legend)" />
</svg>
</div>
<span class="text-[11px] text-muted-foreground">Traffic intensity</span>
<span class="text-[11px] text-muted-foreground">Last month</span>
</div>
</div>
</div>
</div>
</DashboardCard>