Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/web/chat/components/ui/toggle-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client"

import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { toggleVariants } from "@/components/ui/toggle"

const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})

function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}

function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)

return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}

export { ToggleGroup, ToggleGroupItem }
42 changes: 42 additions & 0 deletions apps/web/chat/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}

export { Toggle, toggleVariants }
2 changes: 1 addition & 1 deletion apps/web/chat/turnstile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const WARM_TTL_MS = 240_000
async function loaded(timeoutMs = 15_000): Promise<Turnstile> {
const start = Date.now()
while (!window.turnstile) {
if (Date.now() - start > timeoutMs) throw new Error('確認の部品を読み込めませんでした')
if (Date.now() - start > timeoutMs) throw new Error('チャットを始めるための確認を読み込めませんでした。ページを再読み込みしてください。')
await new Promise((r) => setTimeout(r, 100))
}
return window.turnstile
Expand Down
6 changes: 3 additions & 3 deletions apps/web/jurisdiction/html.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 自治体の個別ページ(/:都道府県/:名称)と一覧(/jurisdictions)の HTML。検索エンジンが読めるよう、金額と款・事業はサーバーで書き出す。
// グラフと会話への導線は島(jurisdiction/jurisdiction.tsx)が同じデータで描き直す
import { TOKENS_CSS } from '../landing'
import { logo, TOKENS_CSS, TURNSTILE_SCRIPT } from '../landing'
import { PWA_HEAD, PWA_REGISTER } from '../pwa'
import type { CatalogEntry } from '../server/jurisdiction'
import { formatYen, jurisdictionPath, reiwa, type PageData } from './types'
Expand Down Expand Up @@ -32,7 +32,7 @@ ${PWA_HEAD}
${extra}`

export const INDEX_PATH = '/' + encodeURIComponent('自治体')
const header = `<header class="j-top"><a class="j-brand" href="/"><svg width="22" height="22" viewBox="0 0 32 32" aria-hidden="true"><g fill="var(--primary)"><rect x="2" y="14" width="4" height="14" rx="2"/><rect x="8" y="6" width="4" height="22" rx="2"/><rect x="14" y="18" width="4" height="10" rx="2"/><rect x="20" y="2" width="4" height="26" rx="2"/><rect x="26" y="11" width="4" height="17" rx="2"/></g><rect x="2" y="29" width="28" height="2" rx="1" fill="var(--logo-rule-ink)"/></svg>風土記</a><nav><a href="/自治体">自治体を探す</a><a href="https://docs.fudoki.dev/">使い方</a></nav></header>`
const header = `<header class="j-top"><a class="j-brand" href="/" aria-label="風土記 ホーム">${logo('j-logo')}</a><nav><a href="/自治体">自治体を探す</a><a href="https://docs.fudoki.dev/">使い方</a></nav></header>`

/** 個別ページの説明文。検索結果に出るので、金額は API の値だけで書く(人口や仮の値は入れない) */
export function pageDescription(d: PageData): string {
Expand Down Expand Up @@ -62,7 +62,7 @@ export function renderPage(d: PageData, siteKey: string): string {
return `<!doctype html>
<html lang="ja">
<head>
${head(title, description, canonical, `<script type="application/ld+json">${inlineJson(ld)}</script>\n<script type="module" src="/_app/jurisdiction.js"></script>`)}
${head(title, description, canonical, `${siteKey ? `${TURNSTILE_SCRIPT}\n` : ''}<script type="application/ld+json">${inlineJson(ld)}</script>\n<script type="module" src="/_app/jurisdiction.js"></script>`)}
</head>
<body>
${header}
Expand Down
10 changes: 5 additions & 5 deletions apps/web/jurisdiction/jurisdiction.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* 自治体の個別ページ(/jurisdictions/*)の CSS。会話の画面と同じ shadcn の token */
/* 自治体の個別ページの CSS。会話の画面と同じ shadcn の token */
@import "tailwindcss" source(none);
@import "../chat/theme.css";
@source "./";
Expand All @@ -13,18 +13,18 @@
a { color: inherit; }
}
@theme inline {
--font-display: "Fudoki Mincho", "BIZ UDPMincho", "Hiragino Mincho ProN", "Yu Mincho", serif;
--font-sans: "Hiragino Sans", "Noto Sans JP", "Yu Gothic UI", "Yu Gothic", Meiryo, system-ui, sans-serif;
}

.j-top { display: flex; align-items: center; justify-content: space-between; height: 60px; padding: 0 24px; max-width: 1200px; margin: 0 auto; }
.j-brand { display: flex; align-items: center; gap: 8px; font: 400 18px/1 var(--font-display); text-decoration: none; }
.j-brand { display: flex; align-items: center; text-decoration: none; }
.j-logo { display: block; width: auto; height: 28px; color: var(--primary); }
.j-top nav { display: flex; gap: 20px; font-size: 13px; color: var(--muted-foreground); }
.j-top nav a { text-decoration: none; }
/* JavaScript が動くまで・動かないときの本文。島が描き直すと消える */
.j-static { max-width: 760px; margin: 0 auto; padding: 24px; }
.j-static h1 { font: 400 2.4rem/1.3 var(--font-display); }
.j-static h2 { font: 400 1.2rem/1.5 var(--font-display); margin-top: 2rem; }
.j-static h1 { font: 400 2.4rem/1.3 var(--font-sans); }
.j-static h2 { font: 400 1.2rem/1.5 var(--font-sans); margin-top: 2rem; }
.j-static table { width: 100%; border-collapse: collapse; }
.j-static th, .j-static td { padding: 6px 4px; border-bottom: 1px solid var(--border); text-align: left; font-weight: 400; }
.j-static td { text-align: right; font-variant-numeric: tabular-nums; }
Expand Down
63 changes: 43 additions & 20 deletions apps/web/jurisdiction/jurisdiction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { createRoot } from 'react-dom/client'
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, LabelList, ReferenceLine, XAxis, YAxis } from 'recharts'
import { ChevronDownIcon } from 'lucide-react'
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
import { Button } from '@/components/ui/button'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { StartForm } from '../chat/start-form'
import { DUMMY_TRAITS, dummyPopulation } from './dummy'
import { Sankey } from './sankey'
import { changePct, formatYen, kanChanges, reiwa, type Drill, type PageData, type Project, type Setsu } from './types'
import { changePct, formatYen, kanChanges, reiwa, type Drill, type FlowDepth, type PageData, type Project, type Setsu } from './types'

const fetchJson = async <T,>(url: string): Promise<T> => {
const res = await fetch(url)
const fetchJson = async <T,>(url: string, signal?: AbortSignal): Promise<T> => {
const res = await fetch(url, { signal })
if (!res.ok) throw new Error(String(res.status))
return (await res.json()) as T
}
Expand All @@ -21,7 +23,7 @@ const Dummy = () => <span className="ml-1.5 inline-block rounded border border-d
const Card = ({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) => (
<section className="mt-4 min-w-0 rounded-[20px] border bg-background p-5 sm:p-6">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 className="font-display text-xl font-normal">{title}</h2>
<h2 className="font-sans text-xl font-normal">{title}</h2>
{aside}
</div>
{children}
Expand All @@ -48,7 +50,7 @@ const BigNumber = ({ label, dummy, yen, perPerson }: { label: string; dummy?: bo
{label}
{dummy ? <Dummy /> : null}
</div>
<div className="font-display text-[clamp(2rem,1.5rem+2vw,3.2rem)] leading-tight tabular-nums">
<div className="font-sans text-[clamp(2rem,1.5rem+2vw,3.2rem)] leading-tight tabular-nums">
{n}
<small className="ml-1 text-[.4em] text-muted-foreground">{unit}</small>
</div>
Expand Down Expand Up @@ -127,21 +129,42 @@ function Trend({ data, pop }: { data: PageData; pop: number }) {

function Flow({ data }: { data: PageData }) {
const latest = data.years.at(-1)!
const [selected, setSelected] = React.useState(data.flow.kan.id)
const [drills, setDrills] = React.useState<Record<string, Drill>>({ [data.flow.kan.id]: data.flow })
const [depth, setDepth] = React.useState<FlowDepth>('kou')
const [flows, setFlows] = React.useState<Partial<Record<FlowDepth, Drill[]>>>({ kou: data.flow })
const [error, setError] = React.useState(false)
const select = (id: string) => {
setSelected(id)
const [attempt, setAttempt] = React.useState(0)
React.useEffect(() => {
setError(false)
if (drills[id]) return
fetchJson<Drill>(`/api/jurisdictions/drill/${id}`).then((d) => setDrills((s) => ({ ...s, [id]: d })), () => setError(true))
}
const drill = drills[selected] ?? null
if (flows[depth]) return
const controller = new AbortController()
fetchJson<Drill[]>(`/api/jurisdictions/flow/${data.code}/${latest.year}/${depth}`, controller.signal).then(
(value) => { if (!controller.signal.aborted) setFlows((current) => ({ ...current, [depth]: value })) },
() => { if (!controller.signal.aborted) setError(true) },
)
return () => controller.abort()
}, [data.code, latest.year, depth, flows, attempt])
const current = flows[depth]
return (
<Card title="お金の流れ" aside={<span className="text-xs text-muted-foreground">款を選ぶと、右へ項・目・節に分かれます{data.originTitle ? ` · 出典 ${data.originTitle}` : ''}</span>}>
<Sankey kan={latest.kan.filter((k) => k.yen > 0)} selectedId={selected} drill={drill} onSelect={select} />
{!drill && !error ? <p className="text-sm text-muted-foreground">内訳を読み込んでいます…</p> : null}
{error ? <p role="alert" className="text-sm text-destructive">内訳を読み込めませんでした。少し待ってから、もう一度選んでください。</p> : null}
<Card title="お金の流れ" aside={
<ToggleGroup aria-label="お金の流れの表示階層" variant="outline" value={[depth]} onValueChange={(values) => {
const value = values[0]
if (value === 'kou' || value === 'moku' || value === 'setsu') setDepth(value)
}}>
<ToggleGroupItem value="kou">項まで</ToggleGroupItem>
<ToggleGroupItem value="moku">目まで</ToggleGroupItem>
<ToggleGroupItem value="setsu">節まで</ToggleGroupItem>
</ToggleGroup>
}>
<p className="mb-3 text-xs text-muted-foreground">すべての款を同じ階層まで表示します。図の中をスクロールすると、全体を確かめられます。</p>
<div aria-busy={!current && !error}>
<Sankey flows={current ?? data.flow} depth={current ? depth : 'kou'} />
</div>
{!current && !error ? <p role="status" className="mt-2 text-sm text-muted-foreground">内訳を読み込んでいます…</p> : null}
{error ? <div role="alert" className="mt-2 flex flex-wrap items-center gap-2 text-sm text-destructive">
<p>内訳を読み込めませんでした。</p>
<Button variant="outline" onClick={() => setAttempt((n) => n + 1)}>もう一度読み込む</Button>
</div> : null}
{data.originTitle ? <p className="mt-2 text-xs text-muted-foreground">出典 {data.originTitle}</p> : null}
</Card>
)
}
Expand All @@ -158,7 +181,7 @@ function ProjectRow({ p, rank, max }: { p: Project; rank: number; max: number })
return (
<li className="border-t">
<div className="grid grid-cols-[22px_minmax(0,1fr)_auto_32px] items-center gap-x-3.5 gap-y-1 py-3 sm:grid-cols-[22px_minmax(120px,190px)_1fr_110px_90px_32px]">
<span className="font-display text-xl text-muted-foreground">{rank}</span>
<span className="font-sans text-xl text-muted-foreground">{rank}</span>
<div className="min-w-0">
<b className="block text-[15px] font-medium leading-snug">{p.name}</b>
<span className="text-xs text-muted-foreground">{p.kanName}</span>
Expand Down Expand Up @@ -220,7 +243,7 @@ function Population({ code }: { code: string }) {
<div className="grid items-end gap-6 md:grid-cols-2">
<div>
<div className="text-[13px] text-muted-foreground">現在の人口</div>
<div className="font-display text-5xl tabular-nums">
<div className="font-sans text-5xl tabular-nums">
{pop.now}
<small className="ml-1 text-[.4em] text-muted-foreground">万人</small>
</div>
Expand Down Expand Up @@ -261,7 +284,7 @@ function Page({ data, sitekey }: { data: PageData; sitekey: string }) {
</span>
<span className="rounded-full border px-3 py-1">{reiwa(latest.year)} 決算</span>
</div>
<h1 className="mt-3 font-display text-[clamp(2.4rem,1.5rem+3.4vw,4rem)] font-normal leading-tight">{data.name}</h1>
<h1 className="mt-3 font-sans text-[clamp(2.4rem,1.5rem+3.4vw,4rem)] font-normal leading-tight">{data.name}</h1>
<p className="mt-1.5 text-[15px] text-muted-foreground">予算の使いみちから、気になる事業まで。そのまま下から聞けます。</p>
<div className="mt-7 flex gap-6 sm:gap-14">
<BigNumber label="歳出の総額" yen={latest.yen} />
Expand Down
Loading
Loading