ランキングLeaderboardExperimental
順位、値、バー、前期比をまとめたランキングリストです。並び順は呼び出し側が決めます。
プレビュー
状態とバリエーション
要対応順
事故率やコストなど、上昇が悪い指標では deltaTones を反転します。
上位順
処理件数や売上など、値が大きいほど良いランキングにも使えます。
バーなし
値の比較より順位ラベルを優先する場合は showBar={false} にします。
プロパティ
表は横にスクロールできます
| プロパティ | 型 | 初期値 | 説明 |
|---|---|---|---|
| items | LeaderboardItem[] | - | 事前に並び替え済みの行です。順位は配列位置で決まります。 |
| label | ReactNode | - | ランキングのアクセシブル名です。 |
| showRank | boolean | true | 番号付きランクチップを表示します。 |
| showBar | boolean | true | 値のバーを表示します。 |
| max | number | - | バー正規化の最大値です。省略時は最大 value です。 |
| formatValue / formatDelta | (value) => ReactNode | - | 値と前期比の表示を整形します。 |
| deltaTones | { positive?, negative?, zero? } | - | 上昇が悪い指標では positive を destructive にできます。 |
| highlightTop | number | 3 | 上位 N 件のランクチップを強調します。 |
| selectedId | string | number | - | 選択中の行を強調します。 |
| LeaderboardItem.onSelect | () => void | - | 行を選択可能なボタンにします。 |
使い方
import * as React from "react";
import {
Badge,
Leaderboard,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
type LeaderboardItem,
} from "@gunjo/ui";
import { IconBus, IconMapPin } from "@tabler/icons-react";
const routes: LeaderboardItem[] = [
{
id: "shibuya88",
label: "渋88 系統",
sublabel: "渋谷↔六本木",
value: 3.4,
delta: 0.9,
tone: "destructive",
icon: <IconBus className="h-4 w-4" />,
},
{
id: "shuku51",
label: "宿51 系統",
sublabel: "新宿↔渋谷",
value: 2.8,
delta: 0.3,
tone: "destructive",
icon: <IconBus className="h-4 w-4" />,
},
{
id: "to07",
label: "都07 系統",
sublabel: "錦糸町↔門前仲町",
value: 1.2,
delta: -0.2,
tone: "warning",
icon: <IconMapPin className="h-4 w-4" />,
},
{
id: "km01",
label: "海01 系統",
sublabel: "門前仲町↔東京テレポート",
value: 0.7,
delta: -0.4,
tone: "success",
icon: <IconBus className="h-4 w-4" />,
},
{
id: "shina97",
label: "品97 系統",
sublabel: "品川↔新宿",
value: 0.6,
delta: -0.1,
tone: "success",
icon: <IconBus className="h-4 w-4" />,
},
{
id: "higashi15",
label: "東15 系統",
sublabel: "東京駅↔深川車庫",
value: 0.4,
delta: 0,
tone: "info",
icon: <IconBus className="h-4 w-4" />,
},
{
id: "to05",
label: "都05 系統",
sublabel: "東京駅↔晴海埠頭",
value: 0,
delta: 0,
tone: "success",
icon: <IconMapPin className="h-4 w-4" />,
},
];
const routeDetails = {
shibuya88: {
status: "重点対応",
scope: "渋谷から六本木の運行区間",
owner: "城南営業所",
period: "2026年6月",
next: "朝ピーク帯のヒヤリハット記録を翌営業日までに確認します。",
},
shuku51: {
status: "要確認",
scope: "新宿から渋谷の運行区間",
owner: "新宿営業所",
period: "2026年6月",
next: "交差点付近の遅延要因と運転記録を確認します。",
},
to07: {
status: "改善傾向",
scope: "錦糸町から門前仲町の運行区間",
owner: "深川営業所",
period: "2026年6月",
next: "改善施策を継続し、雨天日の発生率だけを追加で確認します。",
},
km01: {
status: "安定",
scope: "門前仲町から東京テレポートの運行区間",
owner: "臨海営業所",
period: "2026年6月",
next: "現行運用を継続し、次月も同じ指標で比較します。",
},
shina97: {
status: "安定",
scope: "品川から新宿の運行区間",
owner: "品川営業所",
period: "2026年6月",
next: "現行の安全確認フローを継続します。",
},
higashi15: {
status: "通常監視",
scope: "東京駅から深川車庫の運行区間",
owner: "深川営業所",
period: "2026年6月",
next: "通常の月次レビューで推移を確認します。",
},
to05: {
status: "良好",
scope: "東京駅から晴海埠頭の運行区間",
owner: "晴海営業所",
period: "2026年6月",
next: "改善値を維持できているか来月も確認します。",
},
};
export function IncidentLeaderboard() {
const [selectedId, setSelectedId] = React.useState<string | number | undefined>("shibuya88");
const [compactDetailOpen, setCompactDetailOpen] = React.useState(false);
const [portalContainer, setPortalContainer] = React.useState<HTMLDivElement | null>(null);
const [isCompactLayout, setIsCompactLayout] = React.useState(false);
const selectedRoute = routes.find((route) => route.id === selectedId);
const selectedDetail = selectedId ? routeDetails[selectedId as keyof typeof routeDetails] : undefined;
React.useEffect(() => {
if (!portalContainer) return;
const updateLayout = () => {
setIsCompactLayout(portalContainer.getBoundingClientRect().width < 720);
};
updateLayout();
const observer = new ResizeObserver(updateLayout);
observer.observe(portalContainer);
return () => observer.disconnect();
}, [portalContainer]);
React.useEffect(() => {
if (!isCompactLayout) {
setCompactDetailOpen(false);
}
}, [isCompactLayout]);
const items = routes.map((route) => ({
...route,
onSelect: () => {
setSelectedId(route.id);
setCompactDetailOpen(isCompactLayout);
},
}));
return (
<div
ref={setPortalContainer}
className={
isCompactLayout
? "relative grid w-full max-w-5xl grid-cols-[minmax(0,1fr)] gap-4 overflow-visible rounded-lg border bg-card p-4"
: "relative grid w-full max-w-5xl grid-cols-[minmax(0,1fr)] gap-4 overflow-hidden rounded-lg border bg-card p-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,320px)]"
}
>
<Leaderboard
items={items}
label="系統別 事故率ランキング"
selectedId={selectedId}
formatValue={(value) => value === 0 ? "0件" : value.toFixed(1) + "件"}
deltaTones={{ positive: "destructive", negative: "success" }}
/>
<Sheet
open={isCompactLayout && compactDetailOpen && Boolean(selectedRoute && selectedDetail)}
onOpenChange={setCompactDetailOpen}
>
<SheetContent
side="bottom"
portalContainer={portalContainer}
closeLabel="閉じる"
overlayClassName="bg-overlay/45"
className="max-h-[calc(100%-2rem)] overflow-y-auto rounded-t-lg p-4"
>
<SheetHeader className="pr-8 text-left">
<SheetTitle>ランキング詳細</SheetTitle>
{selectedRoute ? (
<SheetDescription>{selectedRoute.label} / {selectedRoute.sublabel}</SheetDescription>
) : null}
</SheetHeader>
{selectedRoute && selectedDetail ? (
<div className="mt-4">
<div className="min-w-0 rounded-lg border border-l-4 border-primary bg-background p-3 text-sm shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-base font-semibold text-foreground">{selectedRoute.label}</p>
<p className="mt-1 text-xs text-muted-foreground">{selectedRoute.sublabel}</p>
</div>
<Badge
variant="outline"
className="shrink-0 border-border bg-background text-foreground shadow-sm"
>{selectedDetail.status}</Badge>
</div>
<dl className="mt-3 grid gap-2 text-xs">
<div>
<dt className="text-muted-foreground">指標値</dt>
<dd className="font-medium text-foreground">
{selectedRoute.value === 0 ? "0件" : selectedRoute.value.toFixed(1) + "件"}
</dd>
</div>
<div>
<dt className="text-muted-foreground">前月比</dt>
<dd className="font-medium text-foreground">
{selectedRoute.delta && selectedRoute.delta > 0 ? "+" : ""}{selectedRoute.delta?.toFixed(1)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">対象範囲</dt>
<dd className="font-medium text-foreground">
{selectedDetail.scope}
</dd>
</div>
<div>
<dt className="text-muted-foreground">担当</dt>
<dd className="font-medium text-foreground">
{selectedDetail.owner}
</dd>
</div>
<div>
<dt className="text-muted-foreground">集計期間</dt>
<dd className="font-medium text-foreground">
{selectedDetail.period}
</dd>
</div>
<div>
<dt className="text-muted-foreground">次の対応</dt>
<dd className="font-medium text-foreground">
{selectedDetail.next}
</dd>
</div>
</dl>
</div>
</div>
) : null}
</SheetContent>
</Sheet>
{!isCompactLayout && selectedRoute && selectedDetail ? (
<aside className="hidden min-w-0 rounded-lg border bg-muted/20 p-3 lg:block">
<h3 className="mb-3 text-sm font-semibold text-foreground">ランキング詳細</h3>
<div className="min-w-0 rounded-lg border border-l-4 border-primary bg-background p-3 text-sm shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-base font-semibold text-foreground">{selectedRoute.label}</p>
<p className="mt-1 text-xs text-muted-foreground">{selectedRoute.sublabel}</p>
</div>
<Badge
variant="outline"
className="shrink-0 border-border bg-background text-foreground shadow-sm"
>{selectedDetail.status}</Badge>
</div>
<dl className="mt-3 grid gap-2 text-xs">
<div>
<dt className="text-muted-foreground">指標値</dt>
<dd className="font-medium text-foreground">
{selectedRoute.value === 0 ? "0件" : selectedRoute.value.toFixed(1) + "件"}
</dd>
</div>
<div>
<dt className="text-muted-foreground">前月比</dt>
<dd className="font-medium text-foreground">
{selectedRoute.delta && selectedRoute.delta > 0 ? "+" : ""}{selectedRoute.delta?.toFixed(1)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">対象範囲</dt>
<dd className="font-medium text-foreground">
{selectedDetail.scope}
</dd>
</div>
<div>
<dt className="text-muted-foreground">担当</dt>
<dd className="font-medium text-foreground">
{selectedDetail.owner}
</dd>
</div>
<div>
<dt className="text-muted-foreground">集計期間</dt>
<dd className="font-medium text-foreground">
{selectedDetail.period}
</dd>
</div>
<div>
<dt className="text-muted-foreground">次の対応</dt>
<dd className="font-medium text-foreground">
{selectedDetail.next}
</dd>
</div>
</dl>
</div>
</aside>
) : null}
</div>
);
}