Back to index

Bubble Portfolio

Matrix
Generates documents
Bubble Portfolio

Seen as a design

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

bubble-portfolio-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

1.4 KB
Build an HTML document with "Bubble Portfolio". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
Bubble Portfolio with explicit source values and reading conditions.

## What you have to supply
- Provide the source values, labels and decision conditions described by the schema.

## The shape of the input
- `title` — string — 結論を伝える見出し
- `subtitle` — string? — 出典・時点・母集団や仮定
- `xLabel` — string — 横軸名と単位
- `yLabel` — string — 縦軸名と単位
- `sizeLabel` — string — 面積で示す量の単位
- `items` — array (label, x, y, size) — 位置と正の規模。面積はsizeに比例

schema.ts holds the machine-checkable validation. Ask rather than guess when an input is missing.

## When not to use it
- Do not compare values with incompatible units, populations, or assumptions.

## How to build it
Put the input in a JSON file and run:

```bash
node scripts/render.mjs bubble-portfolio <input.json> --theme ink -o out.html
```

The output is one self-contained file and fetches nothing at render time.

## The source
- https://design-vault.pages.dev/designs/bubble-portfolio/source/template.tsx
- https://design-vault.pages.dev/designs/bubble-portfolio/source/schema.ts
- https://design-vault.pages.dev/designs/bubble-portfolio/source/sample-data.json
- https://design-vault.pages.dev/designs/bubble-portfolio/sample.html

Files

Template, input schema, sample data and metadata. This document was generated from those four. The generated document

template.tsx

7.8 KB

/designs/bubble-portfolio/source/template.tsx

Show contents
import type { ReactNode } from "react";
import type { Props } from "./schema";


const fmt = (n: number) => Number.isInteger(n) ? n.toLocaleString("ja-JP") : n.toLocaleString("ja-JP", {maximumFractionDigits: 2});
const colors = ["var(--color-tertiary)", "var(--color-primary)", "var(--color-secondary)", "var(--color-accent-violet)"];
function Ledger({heads,rows,caption}:{heads:string[];rows:ReactNode[][];caption?:string}) { return <table className="ledger">{caption && <caption>{caption}</caption>}<thead><tr>{heads.map((h,i)=><th key={i} scope="col">{h}</th>)}</tr></thead><tbody>{rows.map((r,i)=><tr key={i}>{r.map((v,j)=><td key={j} data-label={heads[j]}>{v}</td>)}</tr>)}</tbody></table>; }
function Notes({children}:{children:ReactNode}) { return <details><summary>データ・判断条件を確認</summary>{children}</details>; }

const CSS = String.raw`
.asset{font-family:var(--font-sans);color:var(--color-primary);background:var(--color-surface);max-width:var(--container-slide);margin:auto;padding:3rem 2rem;line-height:1.6;font-size:var(--text-body-sm)}
.asset *{box-sizing:border-box}.asset header{margin-bottom:2rem}.asset .eyebrow{font-family:var(--font-mono);font-size:var(--text-label);letter-spacing:.08em;color:var(--color-secondary);margin-bottom:.75rem}.asset h1{font-size:var(--text-h1);font-weight:700;line-height:1.35;letter-spacing:-.025em;max-width:32em}.asset h2{font-size:var(--text-h3);font-weight:600;margin-bottom:.75rem}.asset h3{font-size:var(--text-body-md);font-weight:600}.asset .subtitle,.asset .muted{color:var(--color-secondary)}.asset .subtitle{margin-top:.75rem}.asset .number{font-variant-numeric:tabular-nums;font-family:var(--font-mono)}.asset .hero-number{font-size:var(--text-display);line-height:1.2;font-weight:700;letter-spacing:-.04em}.asset .callout{border-left:.25rem solid var(--color-tertiary);padding:1rem 1.5rem;background:var(--color-surface-subtle);margin-bottom:1.5rem}.asset .grid2{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem}.asset .grid3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1.5rem}.asset .panel{padding:1.5rem;background:var(--color-surface-subtle);border-radius:var(--radius-md);min-width:0}.asset .rule{border-top:1px solid var(--color-outline);padding-top:1rem;margin-top:1rem}.asset .pill{display:inline-block;padding:.25rem .75rem;border:1px solid var(--color-outline);border-radius:var(--radius-full);font-size:var(--text-label)}.asset .focus{background:var(--color-tertiary-container);color:var(--color-on-tertiary-container)}.asset .dark{background:var(--color-primary);color:var(--color-on-primary)}.asset .dark .muted{color:var(--color-on-primary-container)}.asset .row{display:grid;grid-template-columns:12rem minmax(0,1fr) 6rem;gap:1rem;align-items:center;padding:.75rem 0;border-bottom:1px solid var(--color-outline)}.asset .row>*{min-width:0}.asset .track{height:1rem;position:relative;background:var(--color-neutral);border-radius:var(--radius-sm)}.asset .bar{height:100%;background:var(--color-tertiary);border-radius:var(--radius-sm)}.asset .track.tall{height:2rem}.asset .flex{display:flex;gap:1rem;align-items:center;justify-content:space-between;flex-wrap:wrap}.asset .stack>*+*{margin-top:1rem}.asset ul{list-style:disc;padding-left:1.25rem}.asset li+li{margin-top:.5rem}.asset .plot{display:block;width:100%;height:auto;overflow:visible}.asset .plot text{font-family:var(--font-sans);font-size:14px;fill:var(--color-secondary)}.asset .plot .ink{fill:var(--color-primary)}.asset .plot .axis{stroke:var(--color-outline);stroke-width:1}.asset .plot .accent{stroke:var(--color-tertiary);fill:none;stroke-width:3}.asset .legend{display:flex;gap:1.5rem;flex-wrap:wrap;font-size:var(--text-label);margin-bottom:1rem}.asset .legend b{display:inline-block;width:.75rem;height:.75rem;margin-right:.5rem}.asset .ledger{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}.asset .ledger caption{text-align:left;font-weight:600;margin:1rem 0}.asset .ledger th{font-weight:600;color:var(--color-secondary);text-align:left}.asset .ledger th,.asset .ledger td{padding:.75rem 1rem;border-bottom:1px solid var(--color-outline);vertical-align:top;overflow-wrap:anywhere}.asset .ledger th:first-child,.asset .ledger td:first-child{padding-left:0}.asset .ledger th:last-child,.asset .ledger td:last-child{padding-right:0}.asset .ledger tbody tr:hover{background:var(--color-surface-subtle)}.asset details{border-top:1px solid var(--color-outline);margin-top:1.5rem;padding-top:1rem}.asset summary{cursor:pointer;color:var(--color-secondary);padding:.5rem 0;min-height:2.75rem}.asset summary:focus-visible{outline:2px solid var(--color-tertiary);outline-offset:.25rem}.asset .small{font-size:var(--text-label)}.asset .mobile-only{display:none}.asset .horizontal-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem}.asset .board-column{border-top:.25rem solid var(--color-primary);padding-top:1rem;min-width:0}.asset .board-item{padding:1rem 0;border-bottom:1px solid var(--color-outline)}.asset .board-item p{margin-top:.5rem}.asset .statement{font-size:var(--text-h2);line-height:1.5;font-weight:600}.asset .meta-line{margin-top:.75rem;font-size:var(--text-label);color:var(--color-secondary)}.asset pre{white-space:pre-wrap;overflow-wrap:anywhere}.asset footer{margin-top:2rem;font-size:var(--text-label);color:var(--color-secondary)}
@media(max-width:640px){.asset{padding:1.5rem 1rem}.asset h1{font-size:var(--text-h2)}.asset header{margin-bottom:1.5rem}.asset .grid2,.asset .grid3,.asset .horizontal-list{grid-template-columns:1fr}.asset .row{grid-template-columns:minmax(0,1fr) 5rem;gap:.5rem}.asset .row>:first-child{grid-column:1/-1}.asset .desktop-only{display:none!important}.asset .mobile-only{display:block}.asset .panel{padding:1rem}.asset .ledger,.asset .ledger tbody,.asset .ledger tr,.asset .ledger td{display:block}.asset .ledger thead{display:none}.asset .ledger tr{padding:.75rem 0;border-bottom:1px solid var(--color-outline)}.asset .ledger td{display:grid;grid-template-columns:7rem minmax(0,1fr);gap:.75rem;border:0;padding:.25rem 0!important}.asset .ledger td:before{content:attr(data-label);color:var(--color-secondary);font-size:var(--text-label)}.asset .hero-number{font-size:var(--text-h1)}}
@media print{.asset{max-width:none;padding:1rem}.asset details>*{display:block}.asset summary{display:none}}
`;

export default function Template(d: Props) {
const xl=Math.min(...d.items.map(r=>r.x)),xh=Math.max(...d.items.map(r=>r.x)),yl=Math.min(...d.items.map(r=>r.y)),yh=Math.max(...d.items.map(r=>r.y)),sm=Math.max(...d.items.map(r=>r.size));const x=(v:number)=>140+(v-xl)/(xh-xl||1)*640,y=(v:number)=>240-(v-yl)/(yh-yl||1)*150;
return <section className="asset asset-bubble-portfolio"><style>{CSS}</style><header><p className="eyebrow">BUBBLE PORTFOLIO</p><h1>{d.title}</h1>{d.subtitle && <p className="subtitle">{d.subtitle}</p>}</header>
<div className="legend"><span>位置:{d.xLabel} × {d.yLabel}</span><span>面積:{d.sizeLabel}</span></div><svg className="plot desktop-only" viewBox="0 0 960 340" role="img" aria-label="面積比例バブル">{[0,.5,1].map((t,i)=><g key={i}><line className="axis" x1="90" x2="840" y1={240-t*150} y2={240-t*150}/><text x="75" y={245-t*150} textAnchor="end">{fmt(yl+t*(yh-yl))}</text><text x={140+t*640} y="310" textAnchor="middle">{fmt(xl+t*(xh-xl))}</text></g>)}{[...d.items].map((r,i)=><g key={i}><circle cx={x(r.x)} cy={y(r.y)} r={Math.sqrt(r.size/sm)*40} fill="var(--color-tertiary-container)" stroke={colors[0]} strokeWidth="2"/><text x={x(r.x)} y={y(r.y)+5} textAnchor="middle" className="ink">{i+1}</text></g>)}</svg><Ledger heads={["番号 / 候補",d.xLabel,d.yLabel,d.sizeLabel]} rows={d.items.map((r,i)=>[(i+1)+" / "+r.label,r.x,r.y,r.size])}/><Notes><p>半径ではなく面積を規模に比例させています。候補の重なりは表で確認します。</p></Notes>
<footer>規模は正の値。バブルの色は優劣を表さない。</footer></section>;
}

schema.ts

857 B

/designs/bubble-portfolio/source/schema.ts

Show contents
import { z } from "zod";
const text = z.string().trim().min(1).max(160);
const short = z.string().trim().min(1).max(48);
const value = z.number().finite();
const positive = value.positive();
const unique = (values: string[]) => new Set(values).size === values.length;

export const schema = z.object({
  title: text.describe("結論を伝える見出し"),
  subtitle: text.optional().describe("出典・時点・母集団や仮定"),
  xLabel:short.describe("横軸名と単位"),yLabel:short.describe("縦軸名と単位"),sizeLabel:short.describe("面積で示す量の単位"),
  items:z.array(z.object({label:short,x:value,y:value,size:positive}).strict()).min(3).max(8).describe("位置と正の規模。面積はsizeに比例"),
}).strict().refine(d=>unique(d.items.map(r=>r.label)),"候補名は一意");
export type Props = z.infer<typeof schema>;

meta.yaml

1.4 KB

/designs/bubble-portfolio/source/meta.yaml

Show contents
id: bubble-portfolio
name:
  ja: 面積で規模を示すポートフォリオ
  en: Bubble Portfolio
purpose:
  ja: 2軸の位置とバブルの面積で、候補の特性と規模を同時に読む。
  en: Bubble Portfolio with explicit source values and reading conditions.
status: published
still: ./still.png
category: analysis
catalogOrder: 380
jobs:
  - measure-change
useCases:
  - kpi
layoutKinds:
  - matrix
tags:
  - bubble-portfolio
  - analysis
  - offline
  - responsive
whenToUse:
  - ja: 2軸の位置とバブルの面積で、候補の特性と規模を同時に読む。
    en: Use to communicate bubble portfolio.
avoidWhen:
  - ja: 同じ条件・単位で比較できないデータには使わない
    en: Do not compare values with incompatible units, populations, or assumptions.
requiredInputs:
  - ja: 規模は正の値。バブルの色は優劣を表さない。
    en: Provide the source values, labels and decision conditions described by the schema.
relatedLayouts:
  - scatter-relationship
  - matrix-2x2
dataSchema:
  title: string — 結論を伝える見出し
  subtitle: string? — 出典・時点・母集団や仮定
  xLabel: string — 横軸名と単位
  yLabel: string — 縦軸名と単位
  sizeLabel: string — 面積で示す量の単位
  items: array (label, x, y, size) — 位置と正の規模。面積はsizeに比例
sources: []

sample-data.json

645 B

/designs/bubble-portfolio/source/sample-data.json

Show contents
{
  "title": "効果と着手工数を、対象規模と合わせて見る",
  "subtitle": "架空サンプル|レイアウトの使用例",
  "xLabel": "着手工数(人月)",
  "yLabel": "削減時間(時間/月)",
  "sizeLabel": "対象人数",
  "items": [
    {
      "label": "入力支援",
      "x": 3,
      "y": 120,
      "size": 80
    },
    {
      "label": "照会統合",
      "x": 6,
      "y": 190,
      "size": 200
    },
    {
      "label": "通知改善",
      "x": 2,
      "y": 60,
      "size": 160
    },
    {
      "label": "承認自動化",
      "x": 9,
      "y": 260,
      "size": 120
    }
  ]
}