索引に戻る

モザイク構成図

面積
生成可能
モザイク構成図

作品として見る

持ち帰る

コピーして貼るか、ファイルに保存して渡す。ネットにつながらない環境には「全部入り」を 1 つ渡せば足りる。

全部入り

marimekko-composition-everything.md

このパネルの中身を、URL の先にあるファイルの中身まで含めて 1 つにまとめた Markdown

依頼文

1.8 KB
「モザイク構成図」でHTML資料を作ってください。この型は実行できるので、版面を組み直す必要はありません — 必要なのは中身です。

## この型がすること
横幅に対象ごとの総量、高さに構成比を割り当て、市場規模と内訳の両方を一枚で比べる。

## 渡す必要があるもの
- 各対象の同じ単位の非負値を入力する。矩形の幅は合計、縦の分割は合計内の割合を表す。
- 対象は2〜6件。各対象が全体の4%以上を占めること。

## 入力の形
- `title` — string — 結論を伝える見出し
- `subtitle` — string? — 対象・単位・時点・母集団
- `unit` — string — 総量の単位
- `series` — array — 構成要素名、2〜4件
- `items` — array (label, values) — 2〜6対象。合計値は正、全体の4%以上

入力の機械可読な検証は schema.ts が持ちます。足りない入力は推測せず質問してください。

## 向かない場合
- 極端に小さい対象が全体の4%未満になる場合。幅が狭く識別しにくいため、別の図を使う。
- 各対象の総量差を隠して構成比だけを比べる場合は、100%積み上げ棒を使う。

## 作り方
入力を JSON にまとめ、次を実行します。

```bash
node scripts/render.mjs marimekko-composition <入力.json> --theme ink -o out.html
```

出力は 1 ファイルで完結し、外部から何も読み込みません。

## 原本
- https://design-vault.pages.dev/designs/marimekko-composition/source/template.tsx
- https://design-vault.pages.dev/designs/marimekko-composition/source/schema.ts
- https://design-vault.pages.dev/designs/marimekko-composition/source/sample-data.json
- https://design-vault.pages.dev/designs/marimekko-composition/sample.html

ファイル

テンプレート・入力スキーマ・サンプルデータ・メタ情報。この文書はこの 4 つから生成されています。 生成された文書

template.tsx

5.8 KB

/designs/marimekko-composition/source/template.tsx

中身を見る
import type { Props } from "./schema";

const joinIds = (...parts: (string | number)[]) =>
  parts.map((part) => String(part).replace(/[_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`)).join("-");

const COLORS = ["#2878A8", "#D18B35", "#4D8B68", "#8A6FB0"];
const fmt = (n: number) => Number.isInteger(n) ? n.toLocaleString("ja-JP") : n.toLocaleString("ja-JP", { maximumFractionDigits: 1 });

export default function Template(d: Props) {
  const totals = d.items.map(row => row.values.reduce((a, b) => a + b, 0));
  const grandTotal = totals.reduce((a, b) => a + b, 0);
  const titleLines = titleLinesFor(d.title);
  const subtitleLines = d.subtitle ? wrapLines(d.subtitle, 64) : [];
  const titleY = 70;
  const subtitleY = titleY + titleLines.length * 34 + 8;
  const legendY = subtitleY + subtitleLines.length * 22 + 12;
  const plotY = legendY + 34;
  const plot = { x: 120, y: plotY, w: 1040, h: Math.max(150, 530 - plotY) };
  let x = plot.x;
  const rects: { x: number; y: number; w: number; h: number; item: number; series: number }[] = [];
  const boundaries = [plot.x];
  d.items.forEach((row, item) => {
    const width = plot.w * totals[item] / grandTotal;
    let y = plot.y + plot.h;
    row.values.forEach((value, series) => {
      const height = plot.h * value / totals[item];
      y -= height;
      rects.push({ x, y, w: width, h: height, item, series });
    });
    x += width;
    boundaries.push(x);
  });
  return <section className="asset asset-marimekko">
    <style>{`.asset{font-family:var(--font-sans);color:var(--color-on-surface);background:var(--color-surface);max-width:var(--container-slide);margin:auto;padding:2rem;line-height:1.5;font-size:var(--text-body-sm)}.asset *{box-sizing:border-box}.asset h1{font-size:var(--text-h1);line-height:1.3;margin:0 0 .5rem}.muted{color:var(--color-secondary)}.plot{display:block;width:100%;height:auto}.plot .svg-title,.plot .svg-subtitle{display:none}.legend{display:flex;flex-wrap:wrap;gap:.5rem 1.5rem;margin:1rem 0}.legend span{display:flex;gap:.5rem;align-items:center}.swatch{width:1rem;height:1rem;border-radius:2px}.key{font:600 16px var(--font-mono);fill:var(--color-on-surface)}.ledger{width:100%;border-collapse:collapse;margin-top:1.5rem}.ledger th,.ledger td{text-align:left;padding:.65rem .5rem;border-bottom:1px solid var(--color-outline);font-variant-numeric:tabular-nums}.ledger th{color:var(--color-secondary)}.ledger td:not(:first-child),.ledger th:not(:first-child){text-align:right}.explain{margin-top:1.5rem;padding:1rem;background:var(--color-surface-subtle);border-radius:var(--radius-sm)}@media(max-width:640px){.asset{padding:1rem}.asset h1{font-size:var(--text-h2)}.plot{display:none}.ledger,.ledger tbody,.ledger tr,.ledger td{display:block}.ledger thead{display:none}.ledger tr{padding:.75rem 0;border-bottom:1px solid var(--color-outline)}.ledger td{border:0;padding:.2rem 0}.ledger td:not(:first-child){display:inline-block;text-align:left;margin-right:.75rem}.ledger td:not(:first-child)::before{content:attr(data-label) ": ";color:var(--color-secondary)}}`}</style>
    <header><p className="muted">MARIMEKKO · 市場規模 × 構成比</p><h1>{d.title}</h1>{d.subtitle && <p className="muted">{d.subtitle}</p>}</header>
    <div className="legend">{d.series.map((name, i) => <span key={name}><i className="swatch" style={{ background: COLORS[i] }} />{name}</span>)}</div>
    <svg className="plot" data-layout-still="true" xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-label="幅は対象の総量、高さの分割は構成比を示すモザイク図">
      <rect width="1280" height="720" fill="var(--color-surface)" />
      {titleLines.map((line, i) => <text key={i} className="svg-title" x="72" y={titleY + i * 34} fontSize="28" fontWeight="700" fill="var(--color-on-surface)">{line}</text>)}
      {subtitleLines.map((line, i) => <text key={i} className="svg-subtitle" x="72" y={subtitleY + i * 22} fontSize="17" fill="var(--color-secondary)">{line}</text>)}
      {d.series.map((name, i) => <g key={name} transform={`translate(${72 + i * 210} ${legendY})`}><rect width="18" height="18" rx="2" fill={COLORS[i]} /><text x="27" y="15" fontSize="17" fill="var(--color-on-surface)">{name}</text></g>)}
      {rects.map(cell => <rect key={joinIds(cell.item, cell.series)} x={cell.x} y={cell.y} width={cell.w} height={cell.h} fill={COLORS[cell.series]} stroke="var(--color-surface)" strokeWidth="2" />)}
      {d.items.map((_, i) => <text key={i} className="key" x={(boundaries[i] + boundaries[i + 1]) / 2} y={plot.y + plot.h + 36} textAnchor="middle">{String(i + 1).padStart(2, "0")}</text>)}
      <text x="72" y="672" fontSize="16" fill="var(--color-secondary)">幅=総量 / 各帯の高さ=構成比 / 番号は下の明細に対応</text>
    </svg>
    <table className="ledger"><thead><tr><th>番号</th><th>対象</th><th>総量({d.unit})</th>{d.series.map(name => <th key={name}>{name}</th>)}</tr></thead><tbody>{d.items.map((row, i) => <tr key={row.label}><td>{String(i + 1).padStart(2, "0")}</td><td>{row.label}</td><td>{fmt(totals[i])}</td>{row.values.map((value, j) => <td key={d.series[j]} data-label={d.series[j]}>{fmt(value)} · {fmt(value / totals[i] * 100)}%</td>)}</tr>)}</tbody></table>
    <p className="explain">幅は対象ごとの総量に比例し、各対象の内部は構成比100%に正規化しています。幅が小さい対象の細かな値は表で確認してください。</p>
    <footer className="muted">合計の差を保ったまま、構成の違いを比較するための図です。架空のサンプルデータを使用しています。</footer>
  </section>;
}

function titleLinesFor(title: string) {
  return wrapLines(title, 34);
}

function wrapLines(text: string, width: number) {
  return Array.from({ length: Math.ceil([...text].length / width) }, (_, i) => [...text].slice(i * width, (i + 1) * width).join(""));
}

schema.ts

1.3 KB

/designs/marimekko-composition/source/schema.ts

中身を見る
import { z } from "zod";

const text = z.string().trim().min(1).max(160);
const short = z.string().trim().min(1).max(12);
const value = z.number().finite().nonnegative();
const unique = (values: string[]) => new Set(values).size === values.length;

export const schema = z.object({
  title: text.describe("市場または構成の結論"),
  subtitle: text.optional().describe("対象・単位・時点・母集団"),
  unit: z.string().trim().min(1).max(6).describe("総量の単位"),
  series: z.array(z.string().trim().min(1).max(12)).min(2).max(4).describe("各帯の構成要素"),
  items: z.array(z.object({
    label: short.describe("市場・集団名"),
    values: z.array(value).min(2).max(4).describe("各構成要素の実数"),
  }).strict()).min(2).max(6).describe("各矩形の幅は総量、縦分割は構成比を表す"),
}).strict().refine(d => {
  const totals = d.items.map(row => row.values.reduce((a, b) => a + b, 0));
  const grandTotal = totals.reduce((a, b) => a + b, 0);
  return unique(d.series) && d.items.every(row => row.values.length === d.series.length) &&
    totals.every(total => total > 0 && total / grandTotal >= 0.04);
}, "構成要素名と各対象の値の個数を揃え、各総量を正にし、全体の4%以上を保つ");

export type Props = z.infer<typeof schema>;

meta.yaml

2.1 KB

/designs/marimekko-composition/source/meta.yaml

中身を見る
id: marimekko-composition
name:
  ja: モザイク構成図
  en: Marimekko Composition Chart
purpose:
  ja: 横幅に対象ごとの総量、高さに構成比を割り当て、市場規模と内訳の両方を一枚で比べる。
  en: Compare category totals by width and within-category composition by height.
status: published
still: ./still.png
category: analysis
catalogOrder: 730
jobs:
  - measure-change
  - compare-choose
useCases:
  - comparison
  - kpi
layoutKinds:
  - area
tags:
  - marimekko
  - mosaic
  - market-share
  - offline
  - responsive
whenToUse:
  - ja: 対象ごとに総量が異なり、その内訳も同時に比較したい。
    en: Use when category totals differ and their internal composition also matters.
  - ja: 各対象の総量と、構成要素の割合を同じ図で見比べたい。
    en: Use to compare category sizes and component shares in one view.
avoidWhen:
  - ja: 極端に小さい対象が全体の4%未満になる場合。幅が狭く識別しにくいため、別の図を使う。
    en: Avoid when a category is below 4% of the total; its width becomes too narrow to identify.
  - ja: 各対象の総量差を隠して構成比だけを比べる場合は、100%積み上げ棒を使う。
    en: Use a 100% stacked bar when total-size differences should be removed.
requiredInputs:
  - ja: 各対象の同じ単位の非負値を入力する。矩形の幅は合計、縦の分割は合計内の割合を表す。
    en: Provide non-negative values in one unit; width shows the total and vertical subdivisions show within-category shares.
  - ja: 対象は2〜6件。各対象が全体の4%以上を占めること。
    en: Provide 2 to 6 categories, each accounting for at least 4% of the grand total.
relatedLayouts:
  - stacked-composition
  - treemap-budget-allocation
dataSchema:
  title: string — 結論を伝える見出し
  subtitle: string? — 対象・単位・時点・母集団
  unit: string — 総量の単位
  series: array — 構成要素名、2〜4件
  items: array (label, values) — 2〜6対象。合計値は正、全体の4%以上
sources: []

sample-data.json

502 B

/designs/marimekko-composition/source/sample-data.json

中身を見る
{
  "title": "大手3社で市場の78%を占め、構成は異なる",
  "subtitle": "架空の家庭用機器市場|売上額と製品構成",
  "unit": "億円",
  "series": ["標準", "省電力", "高機能"],
  "items": [
    { "label": "北辰", "values": [420, 310, 170] },
    { "label": "青葉", "values": [300, 250, 250] },
    { "label": "東都", "values": [380, 170, 150] },
    { "label": "明光", "values": [220, 140, 90] },
    { "label": "西海", "values": [120, 90, 70] }
  ]
}