feat: add treemap and punchcard heatmap to dashboard L1/L2 (#94)
Treemap: rectangle area = transaction volume, color = SLA compliance (green→red). Shows apps at L1, routes at L2. Click navigates deeper. Punchcard heatmap: 7-day rolling weekday x 24-hour grid showing transaction volume and error patterns. Two side-by-side views (transactions + errors) reveal temporal clustering. Backend: new GET /search/stats/punchcard endpoint aggregating stats_1m_all/app by DOW x hour over rolling 7 days. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import com.cameleer3.server.core.search.SearchResult;
|
|||||||
import com.cameleer3.server.core.search.SearchService;
|
import com.cameleer3.server.core.search.SearchService;
|
||||||
import com.cameleer3.server.core.search.StatsTimeseries;
|
import com.cameleer3.server.core.search.StatsTimeseries;
|
||||||
import com.cameleer3.server.core.search.TopError;
|
import com.cameleer3.server.core.search.TopError;
|
||||||
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -162,6 +163,15 @@ public class SearchController {
|
|||||||
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application));
|
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats/punchcard")
|
||||||
|
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
|
||||||
|
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
|
||||||
|
@RequestParam(required = false) String application) {
|
||||||
|
Instant to = Instant.now();
|
||||||
|
Instant from = to.minus(java.time.Duration.ofDays(7));
|
||||||
|
return ResponseEntity.ok(searchService.punchcard(from, to, application));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/errors/top")
|
@GetMapping("/errors/top")
|
||||||
@Operation(summary = "Top N errors with velocity trend")
|
@Operation(summary = "Top N errors with velocity trend")
|
||||||
public ResponseEntity<List<TopError>> topErrors(
|
public ResponseEntity<List<TopError>> topErrors(
|
||||||
|
|||||||
@@ -399,4 +399,30 @@ public class PostgresStatsStore implements StatsStore {
|
|||||||
Integer count = jdbc.queryForObject(sql, Integer.class, params.toArray());
|
Integer count = jdbc.queryForObject(sql, Integer.class, params.toArray());
|
||||||
return count != null ? count : 0;
|
return count != null ? count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Punchcard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PunchcardCell> punchcard(Instant from, Instant to, String applicationName) {
|
||||||
|
String view = applicationName != null ? "stats_1m_app" : "stats_1m_all";
|
||||||
|
String sql = "SELECT EXTRACT(DOW FROM bucket) AS weekday, " +
|
||||||
|
"EXTRACT(HOUR FROM bucket) AS hour, " +
|
||||||
|
"COALESCE(SUM(total_count), 0) AS total_count, " +
|
||||||
|
"COALESCE(SUM(failed_count), 0) AS failed_count " +
|
||||||
|
"FROM " + view + " WHERE bucket >= ? AND bucket < ?";
|
||||||
|
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
params.add(Timestamp.from(from));
|
||||||
|
params.add(Timestamp.from(to));
|
||||||
|
if (applicationName != null) {
|
||||||
|
sql += " AND application_name = ?";
|
||||||
|
params.add(applicationName);
|
||||||
|
}
|
||||||
|
sql += " GROUP BY weekday, hour ORDER BY weekday, hour";
|
||||||
|
|
||||||
|
return jdbc.query(sql, (rs, rowNum) -> new PunchcardCell(
|
||||||
|
rs.getInt("weekday"), rs.getInt("hour"),
|
||||||
|
rs.getLong("total_count"), rs.getLong("failed_count")),
|
||||||
|
params.toArray());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,4 +83,8 @@ public class SearchService {
|
|||||||
public int activeErrorTypes(Instant from, Instant to, String applicationName) {
|
public int activeErrorTypes(Instant from, Instant to, String applicationName) {
|
||||||
return statsStore.activeErrorTypes(from, to, applicationName);
|
return statsStore.activeErrorTypes(from, to, applicationName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<StatsStore.PunchcardCell> punchcard(Instant from, Instant to, String applicationName) {
|
||||||
|
return statsStore.punchcard(from, to, applicationName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,4 +60,9 @@ public interface StatsStore {
|
|||||||
|
|
||||||
// Count of distinct error types in window
|
// Count of distinct error types in window
|
||||||
int activeErrorTypes(Instant from, Instant to, String applicationName);
|
int activeErrorTypes(Instant from, Instant to, String applicationName);
|
||||||
|
|
||||||
|
// Punchcard: aggregate by weekday (0=Sun..6=Sat) x hour (0-23) over last 7 days
|
||||||
|
List<PunchcardCell> punchcard(Instant from, Instant to, String applicationName);
|
||||||
|
|
||||||
|
record PunchcardCell(int weekday, int hour, long totalCount, long failedCount) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,25 @@ export function useTopErrors(from?: string, to?: string, application?: string, r
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Punchcard (weekday x hour heatmap, rolling 7 days) ────────────────
|
||||||
|
|
||||||
|
export interface PunchcardCell {
|
||||||
|
weekday: number;
|
||||||
|
hour: number;
|
||||||
|
totalCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePunchcard(application?: string) {
|
||||||
|
const refetchInterval = useRefreshInterval(60_000);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['dashboard', 'punchcard', application],
|
||||||
|
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application }),
|
||||||
|
placeholderData: (prev: PunchcardCell[] | undefined) => prev,
|
||||||
|
refetchInterval,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── App settings ──────────────────────────────────────────────────────
|
// ── App settings ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import type { KpiItem, Column } from '@cameleer/design-system';
|
|||||||
import { useGlobalFilters } from '@cameleer/design-system';
|
import { useGlobalFilters } from '@cameleer/design-system';
|
||||||
import { useRouteMetrics } from '../../api/queries/catalog';
|
import { useRouteMetrics } from '../../api/queries/catalog';
|
||||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||||
import { useTimeseriesByApp, useTopErrors, useAllAppSettings } from '../../api/queries/dashboard';
|
import { useTimeseriesByApp, useTopErrors, useAllAppSettings, usePunchcard } from '../../api/queries/dashboard';
|
||||||
import type { AppSettings } from '../../api/queries/dashboard';
|
import type { AppSettings } from '../../api/queries/dashboard';
|
||||||
|
import { Treemap } from './Treemap';
|
||||||
|
import type { TreemapItem } from './Treemap';
|
||||||
|
import { PunchcardHeatmap } from './PunchcardHeatmap';
|
||||||
import type { RouteMetrics } from '../../api/types';
|
import type { RouteMetrics } from '../../api/types';
|
||||||
import {
|
import {
|
||||||
computeHealthDot,
|
computeHealthDot,
|
||||||
@@ -294,6 +297,7 @@ export default function DashboardL1() {
|
|||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
|
||||||
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo);
|
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo);
|
||||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo);
|
const { data: topErrors } = useTopErrors(timeFrom, timeTo);
|
||||||
|
const { data: punchcardData } = usePunchcard();
|
||||||
const { data: allAppSettings } = useAllAppSettings();
|
const { data: allAppSettings } = useAllAppSettings();
|
||||||
|
|
||||||
// Build settings lookup map
|
// Build settings lookup map
|
||||||
@@ -387,6 +391,12 @@ export default function DashboardL1() {
|
|||||||
}));
|
}));
|
||||||
}, [timeseriesByApp]);
|
}, [timeseriesByApp]);
|
||||||
|
|
||||||
|
// Treemap items: one per app, sized by exchange count, colored by SLA
|
||||||
|
const treemapItems: TreemapItem[] = useMemo(
|
||||||
|
() => appRows.map(r => ({ id: r.appId, label: r.appId, value: r.throughput, slaCompliance: r.slaCompliance })),
|
||||||
|
[appRows],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.refreshIndicator}>
|
<div className={styles.refreshIndicator}>
|
||||||
@@ -437,6 +447,30 @@ export default function DashboardL1() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Treemap: app volume vs SLA compliance */}
|
||||||
|
{treemapItems.length > 0 && (
|
||||||
|
<Card title="Application Volume vs SLA Compliance">
|
||||||
|
<Treemap
|
||||||
|
items={treemapItems}
|
||||||
|
width={800}
|
||||||
|
height={250}
|
||||||
|
onItemClick={(id) => navigate(`/dashboard/${id}`)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Punchcard heatmaps: transactions and errors by weekday x hour */}
|
||||||
|
{(punchcardData?.length ?? 0) > 0 && (
|
||||||
|
<div className={styles.chartGrid}>
|
||||||
|
<Card title="Transaction Volume (7-day pattern)">
|
||||||
|
<PunchcardHeatmap cells={punchcardData!} mode="transactions" width={380} height={300} />
|
||||||
|
</Card>
|
||||||
|
<Card title="Error Volume (7-day pattern)">
|
||||||
|
<PunchcardHeatmap cells={punchcardData!} mode="errors" width={380} height={300} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ import {
|
|||||||
useTimeseriesByRoute,
|
useTimeseriesByRoute,
|
||||||
useTopErrors,
|
useTopErrors,
|
||||||
useAppSettings,
|
useAppSettings,
|
||||||
|
usePunchcard,
|
||||||
} from '../../api/queries/dashboard';
|
} from '../../api/queries/dashboard';
|
||||||
import type { TopError } from '../../api/queries/dashboard';
|
import type { TopError } from '../../api/queries/dashboard';
|
||||||
|
import { Treemap } from './Treemap';
|
||||||
|
import type { TreemapItem } from './Treemap';
|
||||||
|
import { PunchcardHeatmap } from './PunchcardHeatmap';
|
||||||
import type { RouteMetrics } from '../../api/types';
|
import type { RouteMetrics } from '../../api/types';
|
||||||
import {
|
import {
|
||||||
trendArrow,
|
trendArrow,
|
||||||
@@ -272,28 +276,34 @@ export default function DashboardL2() {
|
|||||||
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
||||||
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId);
|
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId);
|
||||||
const { data: errors } = useTopErrors(timeFrom, timeTo, appId);
|
const { data: errors } = useTopErrors(timeFrom, timeTo, appId);
|
||||||
|
const { data: punchcardData } = usePunchcard(appId);
|
||||||
const { data: appSettings } = useAppSettings(appId);
|
const { data: appSettings } = useAppSettings(appId);
|
||||||
|
|
||||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||||
|
|
||||||
// Route performance table rows
|
// Route performance table rows
|
||||||
const routeRows: RouteRow[] = useMemo(() =>
|
const routeRows: RouteRow[] = useMemo(() =>
|
||||||
(metrics || []).map((m: RouteMetrics) => {
|
(metrics || []).map((m: RouteMetrics) => ({
|
||||||
const sla = m.p99DurationMs <= slaThresholdMs
|
id: m.routeId,
|
||||||
? 99.9
|
routeId: m.routeId,
|
||||||
: Math.max(0, 100 - ((m.p99DurationMs - slaThresholdMs) / slaThresholdMs) * 10);
|
exchangeCount: m.exchangeCount,
|
||||||
return {
|
successRate: m.successRate,
|
||||||
id: m.routeId,
|
avgDurationMs: m.avgDurationMs,
|
||||||
routeId: m.routeId,
|
p99DurationMs: m.p99DurationMs,
|
||||||
exchangeCount: m.exchangeCount,
|
slaCompliance: (m as Record<string, unknown>).slaCompliance as number ?? -1,
|
||||||
successRate: m.successRate,
|
sparkline: m.sparkline ?? [],
|
||||||
avgDurationMs: m.avgDurationMs,
|
})),
|
||||||
p99DurationMs: m.p99DurationMs,
|
[metrics],
|
||||||
slaCompliance: sla,
|
);
|
||||||
sparkline: m.sparkline ?? [],
|
|
||||||
};
|
// Treemap items: one per route, sized by exchange count, colored by SLA
|
||||||
}),
|
const treemapItems: TreemapItem[] = useMemo(
|
||||||
[metrics, slaThresholdMs],
|
() => routeRows.map(r => ({
|
||||||
|
id: r.routeId, label: r.routeId,
|
||||||
|
value: r.exchangeCount,
|
||||||
|
slaCompliance: r.slaCompliance >= 0 ? r.slaCompliance : 100,
|
||||||
|
})),
|
||||||
|
[routeRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
// KPI sparklines from timeseries
|
// KPI sparklines from timeseries
|
||||||
@@ -416,6 +426,30 @@ export default function DashboardL2() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Treemap: route volume vs SLA compliance */}
|
||||||
|
{treemapItems.length > 0 && (
|
||||||
|
<Card title="Route Volume vs SLA Compliance">
|
||||||
|
<Treemap
|
||||||
|
items={treemapItems}
|
||||||
|
width={800}
|
||||||
|
height={250}
|
||||||
|
onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Punchcard heatmaps: transactions and errors by weekday x hour */}
|
||||||
|
{(punchcardData?.length ?? 0) > 0 && (
|
||||||
|
<div className={styles.chartGrid}>
|
||||||
|
<Card title="Transaction Volume (7-day pattern)">
|
||||||
|
<PunchcardHeatmap cells={punchcardData!} mode="transactions" width={380} height={300} />
|
||||||
|
</Card>
|
||||||
|
<Card title="Error Volume (7-day pattern)">
|
||||||
|
<PunchcardHeatmap cells={punchcardData!} mode="errors" width={380} height={300} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
ui/src/pages/DashboardTab/PunchcardHeatmap.tsx
Normal file
125
ui/src/pages/DashboardTab/PunchcardHeatmap.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export interface PunchcardCell {
|
||||||
|
weekday: number;
|
||||||
|
hour: number;
|
||||||
|
totalCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PunchcardHeatmapProps {
|
||||||
|
cells: PunchcardCell[];
|
||||||
|
mode: 'transactions' | 'errors';
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const LEFT_MARGIN = 28;
|
||||||
|
const TOP_MARGIN = 18;
|
||||||
|
const BOTTOM_MARGIN = 4;
|
||||||
|
const RIGHT_MARGIN = 4;
|
||||||
|
|
||||||
|
function transactionColor(ratio: number): string {
|
||||||
|
if (ratio === 0) return 'hsl(220, 15%, 95%)';
|
||||||
|
const lightness = 90 - ratio * 55;
|
||||||
|
return `hsl(220, 60%, ${Math.round(lightness)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorColor(ratio: number): string {
|
||||||
|
if (ratio === 0) return 'hsl(0, 10%, 95%)';
|
||||||
|
const lightness = 90 - ratio * 55;
|
||||||
|
return `hsl(0, 65%, ${Math.round(lightness)}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PunchcardHeatmap({ cells, mode, width, height }: PunchcardHeatmapProps) {
|
||||||
|
const grid = useMemo(() => {
|
||||||
|
const map = new Map<string, PunchcardCell>();
|
||||||
|
for (const c of cells) {
|
||||||
|
map.set(`${c.weekday}-${c.hour}`, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: number[] = [];
|
||||||
|
for (const c of cells) {
|
||||||
|
values.push(mode === 'errors' ? c.failedCount : c.totalCount);
|
||||||
|
}
|
||||||
|
const maxVal = Math.max(...values, 1);
|
||||||
|
|
||||||
|
const gridWidth = width - LEFT_MARGIN - RIGHT_MARGIN;
|
||||||
|
const gridHeight = height - TOP_MARGIN - BOTTOM_MARGIN;
|
||||||
|
const cellW = gridWidth / 7;
|
||||||
|
const cellH = gridHeight / 24;
|
||||||
|
|
||||||
|
const rects: { x: number; y: number; w: number; h: number; fill: string; value: number; day: string; hour: number }[] = [];
|
||||||
|
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
const cell = map.get(`${d}-${h}`);
|
||||||
|
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
|
||||||
|
const ratio = maxVal > 0 ? val / maxVal : 0;
|
||||||
|
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
|
||||||
|
|
||||||
|
rects.push({
|
||||||
|
x: LEFT_MARGIN + d * cellW,
|
||||||
|
y: TOP_MARGIN + h * cellH,
|
||||||
|
w: cellW,
|
||||||
|
h: cellH,
|
||||||
|
fill,
|
||||||
|
value: val,
|
||||||
|
day: DAYS[d],
|
||||||
|
hour: h,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rects, cellW, cellH };
|
||||||
|
}, [cells, mode, width, height]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} style={{ display: 'block' }}>
|
||||||
|
{/* Day labels (top) */}
|
||||||
|
{DAYS.map((day, i) => (
|
||||||
|
<text
|
||||||
|
key={day}
|
||||||
|
x={LEFT_MARGIN + i * grid.cellW + grid.cellW / 2}
|
||||||
|
y={12}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontSize={9}
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Hour labels (left, every 4 hours) */}
|
||||||
|
{[0, 4, 8, 12, 16, 20].map((h) => (
|
||||||
|
<text
|
||||||
|
key={h}
|
||||||
|
x={LEFT_MARGIN - 4}
|
||||||
|
y={TOP_MARGIN + h * grid.cellH + grid.cellH / 2 + 3}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
fontSize={8}
|
||||||
|
fontFamily="var(--font-mono)"
|
||||||
|
>
|
||||||
|
{String(h).padStart(2, '0')}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Cells */}
|
||||||
|
{grid.rects.map((r) => (
|
||||||
|
<rect
|
||||||
|
key={`${r.day}-${r.hour}`}
|
||||||
|
x={r.x + 0.5}
|
||||||
|
y={r.y + 0.5}
|
||||||
|
width={Math.max(r.w - 1, 0)}
|
||||||
|
height={Math.max(r.h - 1, 0)}
|
||||||
|
rx={1.5}
|
||||||
|
fill={r.fill}
|
||||||
|
>
|
||||||
|
<title>{`${r.day} ${String(r.hour).padStart(2, '0')}:00 — ${r.value.toLocaleString()} ${mode}`}</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
ui/src/pages/DashboardTab/Treemap.tsx
Normal file
159
ui/src/pages/DashboardTab/Treemap.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export interface TreemapItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
/** 0-100, drives green→yellow→red color */
|
||||||
|
slaCompliance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreemapProps {
|
||||||
|
items: TreemapItem[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
onItemClick?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutRect {
|
||||||
|
item: TreemapItem;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slaColor(pct: number): string {
|
||||||
|
if (pct >= 99) return 'hsl(120, 45%, 85%)';
|
||||||
|
if (pct >= 97) return 'hsl(90, 45%, 85%)';
|
||||||
|
if (pct >= 95) return 'hsl(60, 50%, 85%)';
|
||||||
|
if (pct >= 90) return 'hsl(30, 55%, 85%)';
|
||||||
|
return 'hsl(0, 55%, 85%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function slaBorderColor(pct: number): string {
|
||||||
|
if (pct >= 99) return 'hsl(120, 40%, 45%)';
|
||||||
|
if (pct >= 97) return 'hsl(90, 40%, 50%)';
|
||||||
|
if (pct >= 95) return 'hsl(60, 45%, 45%)';
|
||||||
|
if (pct >= 90) return 'hsl(30, 50%, 45%)';
|
||||||
|
return 'hsl(0, 50%, 45%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function slaTextColor(pct: number): string {
|
||||||
|
if (pct >= 95) return 'hsl(120, 20%, 25%)';
|
||||||
|
return 'hsl(0, 40%, 30%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Squarified treemap layout */
|
||||||
|
function layoutTreemap(items: TreemapItem[], x: number, y: number, w: number, h: number): LayoutRect[] {
|
||||||
|
if (items.length === 0) return [];
|
||||||
|
const total = items.reduce((s, i) => s + i.value, 0);
|
||||||
|
if (total === 0) return items.map((item, i) => ({ item, x: x + i, y, w: 1, h: 1 }));
|
||||||
|
|
||||||
|
const sorted = [...items].sort((a, b) => b.value - a.value);
|
||||||
|
const rects: LayoutRect[] = [];
|
||||||
|
layoutSlice(sorted, total, x, y, w, h, rects);
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutSlice(
|
||||||
|
items: TreemapItem[], total: number,
|
||||||
|
x: number, y: number, w: number, h: number,
|
||||||
|
out: LayoutRect[],
|
||||||
|
) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
if (items.length === 1) {
|
||||||
|
out.push({ item: items[0], x, y, w, h });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWide = w >= h;
|
||||||
|
let partialSum = 0;
|
||||||
|
let splitIndex = 0;
|
||||||
|
|
||||||
|
// Find split point closest to half the total area
|
||||||
|
const halfTotal = total / 2;
|
||||||
|
for (let i = 0; i < items.length - 1; i++) {
|
||||||
|
partialSum += items[i].value;
|
||||||
|
if (partialSum >= halfTotal) {
|
||||||
|
splitIndex = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
splitIndex = i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftTotal = items.slice(0, splitIndex).reduce((s, i) => s + i.value, 0);
|
||||||
|
const ratio = total > 0 ? leftTotal / total : 0.5;
|
||||||
|
|
||||||
|
if (isWide) {
|
||||||
|
const splitX = x + w * ratio;
|
||||||
|
layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w * ratio, h, out);
|
||||||
|
layoutSlice(items.slice(splitIndex), total - leftTotal, splitX, y, w * (1 - ratio), h, out);
|
||||||
|
} else {
|
||||||
|
const splitY = y + h * ratio;
|
||||||
|
layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w, h * ratio, out);
|
||||||
|
layoutSlice(items.slice(splitIndex), total - leftTotal, x, splitY, w, h * (1 - ratio), out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Treemap({ items, width, height, onItemClick }: TreemapProps) {
|
||||||
|
const rects = useMemo(() => layoutTreemap(items, 1, 1, width - 2, height - 2), [items, width, height]);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height}>
|
||||||
|
<text x={width / 2} y={height / 2} textAnchor="middle" fill="#9CA3AF" fontSize={12}>No data</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} style={{ display: 'block' }}>
|
||||||
|
{rects.map(({ item, x, y, w, h }) => {
|
||||||
|
const pad = 1;
|
||||||
|
const rx = x + pad;
|
||||||
|
const ry = y + pad;
|
||||||
|
const rw = Math.max(w - pad * 2, 0);
|
||||||
|
const rh = Math.max(h - pad * 2, 0);
|
||||||
|
const showLabel = rw > 40 && rh > 20;
|
||||||
|
const showSla = rw > 60 && rh > 34;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onItemClick?.(item.id)}
|
||||||
|
style={{ cursor: onItemClick ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={rx} y={ry} width={rw} height={rh}
|
||||||
|
rx={3}
|
||||||
|
fill={slaColor(item.slaCompliance)}
|
||||||
|
stroke={slaBorderColor(item.slaCompliance)}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
{showLabel && (
|
||||||
|
<text
|
||||||
|
x={rx + 4} y={ry + 13}
|
||||||
|
fill={slaTextColor(item.slaCompliance)}
|
||||||
|
fontSize={11} fontWeight={600}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{item.label.length > rw / 6.5 ? item.label.slice(0, Math.floor(rw / 6.5)) + '\u2026' : item.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{showSla && (
|
||||||
|
<text
|
||||||
|
x={rx + 4} y={ry + 26}
|
||||||
|
fill={slaTextColor(item.slaCompliance)}
|
||||||
|
fontSize={10} fontWeight={400}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{item.slaCompliance.toFixed(1)}% SLA
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user