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:
@@ -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>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { default as DashboardCard } from "./dashboard-card.svelte";
|
||||||
export { default as FrequentFilters } from "./frequent-filters.svelte";
|
export { default as FrequentFilters } from "./frequent-filters.svelte";
|
||||||
export { default as MetricCard } from "./metric-card.svelte";
|
export { default as MetricCard } from "./metric-card.svelte";
|
||||||
export { default as PeakTrafficChart } from "./peak-traffic-chart.svelte";
|
export { default as PeakTrafficChart } from "./peak-traffic-chart.svelte";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
import DashboardCard from "./dashboard-card.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -7,80 +8,102 @@ interface Props {
|
|||||||
|
|
||||||
let { class: className }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
|
|
||||||
const hours = [
|
const hours = ["0:00", "6:00", "12:00", "15:00", "18:00", "21:00"];
|
||||||
"0:00",
|
|
||||||
"3:00",
|
|
||||||
"6:00",
|
|
||||||
"9:00",
|
|
||||||
"12:00",
|
|
||||||
"15:00",
|
|
||||||
"18:00",
|
|
||||||
"21:00",
|
|
||||||
];
|
|
||||||
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
|
||||||
// Traffic intensity data (0-100)
|
// Traffic data: [startHour, endHour] for each day (0-24 scale)
|
||||||
const heatmapData = [
|
// This month data (solid bars)
|
||||||
[20, 35, 45, 60, 75, 55, 30],
|
const thisMonthData = [
|
||||||
[15, 25, 40, 55, 65, 45, 25],
|
{ start: 6, end: 18 },
|
||||||
[25, 40, 55, 70, 80, 60, 35],
|
{ start: 9, end: 15 },
|
||||||
[35, 50, 65, 80, 90, 70, 45],
|
{ start: 12, end: 18 },
|
||||||
[40, 55, 70, 85, 95, 75, 50],
|
{ start: 15, end: 21 },
|
||||||
[45, 60, 75, 90, 85, 70, 45],
|
{ start: 12, end: 18 },
|
||||||
[50, 65, 80, 75, 70, 55, 40],
|
{ start: 3, end: 12 },
|
||||||
[35, 50, 60, 55, 50, 40, 30],
|
{ start: 9, end: 18 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getCellOpacity(value: number): number {
|
// Last month data (hatched bars)
|
||||||
return 0.15 + (value / 100) * 0.85;
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<DashboardCard title="Peak Traffic Hours" class={className}>
|
||||||
class={cn(
|
<div class="relative">
|
||||||
"overflow-hidden rounded-lg border border-border bg-card shadow-card",
|
<!-- Chart container -->
|
||||||
className,
|
<div class="flex">
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<!-- Y-axis labels -->
|
<!-- 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}
|
{#each hours as hour}
|
||||||
<div class="flex h-6 items-center">
|
|
||||||
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
|
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grid -->
|
<!-- Chart area -->
|
||||||
<div class="flex-1">
|
<div class="relative flex-1">
|
||||||
<div class="grid grid-cols-7 gap-1">
|
<!-- Grid lines -->
|
||||||
{#each hours as _, hourIndex}
|
<div class="absolute inset-0 flex flex-col justify-between" style="height: 210px">
|
||||||
{#each days as _, dayIndex}
|
{#each hours as _}
|
||||||
{@const value = heatmapData[hourIndex][dayIndex]}
|
<div class="h-px w-full bg-border"></div>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<!-- 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>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/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="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)}%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- X-axis labels -->
|
<!-- 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}
|
{#each days as day}
|
||||||
<div class="text-center text-[11px] text-muted-foreground">{day}</div>
|
<div class="text-center text-[11px] text-muted-foreground">{day}</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -89,18 +112,30 @@ function getCellOpacity(value: number): number {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legend -->
|
<!-- 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-2">
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="h-3 w-3 rounded-sm bg-muted-foreground/60"></div>
|
||||||
{#each [0.2, 0.4, 0.6, 0.8, 1] as opacity}
|
<span class="text-[11px] text-muted-foreground">This month</span>
|
||||||
<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>
|
|
||||||
<span class="text-[11px] text-muted-foreground">Traffic intensity</span>
|
|
||||||
</div>
|
</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">Last month</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DashboardCard>
|
||||||
|
|||||||
Reference in New Issue
Block a user