かんたんな説明
町役場のガイドの二ページです。だれが何を受け持つかを線で結んだ箱の図で、各窓口がどこにあるかを写真の列で見せます。ページは使い回せるいくつかのブロックでできていて、いくつ置くかはMarkdownが決めます。
できあがり
バルデロマの暮らしのガイドの見開きです。バルデロマは人口8,400人の架空の町です。左ページは町役場の組織図で、町長と町議会が赤い箱に、四つの部が緑の箱に入り、各部の下に担当窓口が淡い色の箱で並びます。箱どうしは緑の線で結ばれ、線の端には点か矢印が付きます。右ページは窓口の場所を示します。水彩画三枚のパネルが二枚あり、絵の下ごとに名称、住所、開館時間が入ります。さらに住民窓口のための囲みがあります。手で描いたものは一つもありません。テンプレートにはモジュールが三つ(部、写真のパネル、お知らせ)あり、各ページはMarkdownにある数だけそれを積み上げます。文章が箱をどこへ押しても、線は箱に付いていきます。
このレシピが答える質問
- 使い回せるモジュールで組織図と写真のグリッドを作り、線はテンプレートに引かせるには?
手短な答え
// The page template draws the head of the chart and a group that takes the page's modules;
// the module draws one area: its box, a grid of its services and the lines between them.
const area = {
id: 'area', role: 'module',
frames: [
{ name: 'concejalia', style: 'area', width: mm(84), placement: at('container', 'top-left') },
{ name: 'servicio', style: 'servicio' }, // repeated: one per :::frame{slot="servicio"}
],
groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
repeat: { frames: ['servicio'] }, // rowAlign 'stretch': a row's boxes are one height
placement: { ...at('#concejalia', 'below', 0, 7), size: { width: 'fill', height: 'fill' } } }],
decoration: { elements: [
// From the area down to each of its services: a tree with a bar over the row.
line({ id: 'rama', routing: 'tree', from: { to: '#concejalia', side: 'bottom' },
to: { to: '#servicio[*]', side: 'top' }, endCap: 'dot', capSize: mm(1.8) }),
// From the mayor's box, a frame of the page, to this area: the spine and its branch.
line({ id: 'tronco', routing: 'elbow', width: pt(1.8),
from: { to: '#alcaldia', side: 'bottom', at: 0.06 }, to: { to: '#concejalia', side: 'left' },
endCap: 'arrow', capSize: mm(2.6) }),
] },
};
const chart = {
id: 'organigrama',
frames: [
{ name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
{ name: 'entradilla', flow: true, width: mm(W) },
{ name: 'alcaldia', style: 'alcaldia', width: mm(96) },
],
groups: [{ name: 'cabeza', kind: 'stack', gap: mm(6),
frames: ['titulo', 'entradilla', 'alcaldia'] },
{ name: 'areas', modules: true, gap: mm(6), placement: { ...at('#alcaldia', 'below', SPINE, 9),
size: { width: mm(W - SPINE), height: 'fill' } } }],
};
材料
- 機能
- ページに積むモジュール枠をつなぐコネクター枠を繰り返すグリッドテンプレートページグループの中で伸びる枠枠に固定した飾り囲み見開きの余白柱とノンブルリソースとしての図と表セマンティックカラーパレットキャンバス上のページPDFの書き出し
- 種類
- Public Sans, Source Serif 4 (SIL OFL 1.1)
- 素材
biblioteca-900.jpgcentro-mayores-900.jpgmercado-900.jpgmusica-900.jpgpiscina-900.jpgpolideportivo-900.jpg- The reading room of a town library, in watercolour. (Generated With Diffusion Models, オリジナル)
- Children with violins and a piano at a music school, in watercolour. (Generated With Diffusion Models, オリジナル)
- Older people playing cards and painting at a day centre, in watercolour. (Generated With Diffusion Models, オリジナル)
- A covered pool under a timber roof, in watercolour. (Generated With Diffusion Models, オリジナル)
- Children playing basketball in a sports hall, in watercolour. (Generated With Diffusion Models, オリジナル)
- A Saturday market under the arcades of a town square, in watercolour. (Generated With Diffusion Models, オリジナル)
作り方
#1 · 部ごとに一つのモジュール
// The page template draws the head of the chart and a group that takes the page's modules;
// the module draws one area: its box, a grid of its services and the lines between them.
const area = {
id: 'area', role: 'module',
frames: [
{ name: 'concejalia', style: 'area', width: mm(84), placement: at('container', 'top-left') },
{ name: 'servicio', style: 'servicio' }, // repeated: one per :::frame{slot="servicio"}
],
groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
repeat: { frames: ['servicio'] }, // rowAlign 'stretch': a row's boxes are one height
placement: { ...at('#concejalia', 'below', 0, 7), size: { width: 'fill', height: 'fill' } } }],
decoration: { elements: [
// From the area down to each of its services: a tree with a bar over the row.
line({ id: 'rama', routing: 'tree', from: { to: '#concejalia', side: 'bottom' },
to: { to: '#servicio[*]', side: 'top' }, endCap: 'dot', capSize: mm(1.8) }),
// From the mayor's box, a frame of the page, to this area: the spine and its branch.
line({ id: 'tronco', routing: 'elbow', width: pt(1.8),
from: { to: '#alcaldia', side: 'bottom', at: 0.06 }, to: { to: '#concejalia', side: 'left' },
endCap: 'arrow', capSize: mm(2.6) }),
] },
};
const chart = {
id: 'organigrama',
frames: [
{ name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
{ name: 'entradilla', flow: true, width: mm(W) },
{ name: 'alcaldia', style: 'alcaldia', width: mm(96) },
],
groups: [{ name: 'cabeza', kind: 'stack', gap: mm(6),
frames: ['titulo', 'entradilla', 'alcaldia'] },
{ name: 'areas', modules: true, gap: mm(6), placement: { ...at('#alcaldia', 'below', SPINE, 9),
size: { width: mm(W - SPINE), height: 'fill' } } }],
};
role: 'module'を付けたテンプレートがモジュールです。何度も使うための、いくつかの枠とグループと飾りのまとまりです。組織図のページにはmodules: trueのグループがあり、Markdownの:::module{template="area"}が一つ増えるごとに、前の部の6mm下にもう一つ部を置きます。モジュールの枠にはインスタンスの名前が付き、area[1].concejalia、area[2].concejalia…となるので、二本のコネクタは一度書けば済みます。ramaはツリー(tree)で、部の下端からグリッドの各窓口(#servicio[*])へ線を出します。troncoは折れ線(elbow)で、町長の箱の左寄りの一点(at: 0.06)から部の左辺へ引きます。#alcaldiaはモジュールではなくページの枠です。モジュールはその端を持ったままにし、レイアウトがページの上でその枠を見つけます。そのため四本の折れ線は最初の区間を共有し、各部へ矢印を出す一本の幹として読めます。窓口はservicio枠を繰り返す三列のグリッドなので、窓口が二つの部には箱が二つ描かれ、ツリーの枝も二本になります。
#2 · 写真カードのパネル
const gallery = {
id: 'galeria', role: 'module',
frames: [
{ name: 'cabecera', takes: 'h2', placement: at('container', 'top-left', 4, 4) },
{ name: 'foto', style: 'marco' }, // the photo of a card
{ name: 'pie', style: 'ficha', placement: at('#foto', 'below', 0, 2.5) }, // and its words
],
groups: [{ name: 'fotos', kind: 'grid', columns: 3, columnGap: mm(5), rowGap: mm(6),
rowAlign: 'start', repeat: { frames: ['foto', 'pie'] },
placement: { ...at('#cabecera', 'below', 0, 3), size: { width: mm(W - 8), height: 'fill' } } }],
// The module's own ground: a tint as large as the module, whatever its rows.
decoration: { elements: [{ kind: 'box', id: 'suelo', decorative: true,
placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col('tint'), borderRadius: mm(3) } }] },
};
// A notice box as a module of its own: one frame whose tab grows out of the box (joined).
const notice = { id: 'aviso', role: 'module', frames: [{ name: 'texto', style: 'aviso',
placement: at('container', 'top-left', 0, 7) }] };
const places = {
id: 'centros',
frames: [
{ name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left') },
{ name: 'entradilla', flow: true, width: mm(W) },
{ name: 'colofon', style: 'colofon', optional: true, width: mm(W),
placement: at('container', 'bottom-left', 0, 8) },
],
groups: [{ name: 'cabeza', kind: 'stack', gap: mm(5), frames: ['titulo', 'entradilla'] },
{ name: 'galerias', modules: true, gap: mm(7), placement: { ...at('#entradilla', 'below', 0, 7),
size: { width: mm(W), height: 'fill' } } }],
};
パネルもモジュールです。モジュールの##見出しを受ける見出し枠と、写真とその下の文字という二つの枠の組を繰り返すグリッドでできています。:::frame{slot="foto"}の一つ一つがカードで、その中に書いた:::frame{slot="pie"}がそのカードの文字に入ります。色の付いた地はモジュールの飾りで、モジュールと同じ大きさの箱です。そのため、行の数にもいちばん長いキャプションにも合わせて伸びます。住民窓口は三つ目のモジュールで、タブがjoinedの囲みです。タブと囲みは一つの輪郭、一つの塗りになります。
#3 · 印刷の大きさで置く絵
const ART = {
biblioteca: t({ es: 'La sala de lectura de la biblioteca, con estanterías de madera y vecinos '
+ 'de todas las edades leyendo en mesas largas.',
en: 'The library reading room, with wooden shelves and neighbours of every age reading at '
+ 'long tables.' }),
musica: t({ es: 'Un aula de la escuela de música: niños con violines, una profesora y una niña '
+ 'al piano.',
en: 'A room of the music school: children with violins, a teacher and a girl at the piano.' }),
'centro-mayores': t({ es: 'El centro de día: mayores jugando a las cartas y pintando junto a '
+ 'una puerta al jardín.',
en: 'The day centre: older people playing cards and painting beside a door to the garden.' }),
piscina: t({ es: 'La piscina cubierta bajo un techo de madera, con nadadores en las calles y un '
+ 'socorrista.',
en: 'The covered pool under a timber roof, with swimmers in the lanes and a lifeguard.' }),
polideportivo: t({ es: 'La pista del polideportivo, con niños jugando al baloncesto y gradas a '
+ 'un lado.',
en: 'The sports hall court, with children playing basketball and stands along one side.' }),
mercado: t({ es: 'El mercado de los sábados en los soportales de la plaza, con el ayuntamiento '
+ 'al fondo.',
en: 'The Saturday market under the arcades of the square, with the town hall behind.' }),
};
const resources = await Promise.all(Object.entries(ART).map(async ([id, altText]) => {
const fileId = `${id}-900.jpg`;
await loadImage(fileId, asset(fileId));
return { id, typeId: 'foto', kind: 'bitmap', caption: '', altText, createdAt: 0, updatedAt: 0,
bitmap: { fileId, format: 'jpeg', width: 900, height: 600, resolution: 400 } };
}));
六枚の水彩画は900 × 600pxで400ppiと宣言してあり、幅は57mm、グリッドの一列とほぼ同じ幅です。グリッドが絵を拡大することはありません。代替テキストをここのスクリプトに書いたのは、二つの版で絵が同じだからです。言語ごとに変わるキャプションなら、Markdownの::captionの行になります。
#4 · 線と箱に一つの緑
const palette = {
ink: '#1f2b2a', // text
green: '#1d6b57', // the lines of the chart, the areas' boxes, the headings
mayor: '#a8452e', // the box at the head of the chart
tint: '#e6f1ec', // the services' boxes and the galleries' ground
rule: '#b9cbc3', // the hairline round a service
muted: '#5c6b67', // running heads and the colophon
paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
{ id: 'main-color', name: 'green (defaults)', value: { hex: palette.green, model: 'hex' } },
];
コネクタ、部の箱、パネルの見出しはパレットの項目greenを使います。別の色の町なら、16進の値を一つ変えるだけです。町長の箱には見開きで唯一の暖色を使いました。読者が最初に目を向ける場所です。
レシピの全体
// ═══ Postext Cookbook · Nº 162 · An org chart and a photo grid built from modules ═══
// https://postext.dev/en/cookbook/org-chart-from-modules
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Public Sans, Source Serif 4 (SIL OFL 1.1) · Needs postext ≥ 1.48.1
import {
buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'org-chart-from-modules';
// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: an ink, the town's green for the lines and the areas, a warm mayor's box
const palette = {
ink: '#1f2b2a', // text
green: '#1d6b57', // the lines of the chart, the areas' boxes, the headings
mayor: '#a8452e', // the box at the head of the chart
tint: '#e6f1ec', // the services' boxes and the galleries' ground
rule: '#b9cbc3', // the hairline round a service
muted: '#5c6b67', // running heads and the colophon
paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
{ id: 'main-color', name: 'green (defaults)', value: { hex: palette.green, model: 'hex' } },
];
// #endregion
const SANS = 'Public Sans', SERIF = 'Source Serif 4';
const PAGE = { w: 210, h: 297, top: 22, bottom: 22, inner: 18, outer: 15 }; // mm, mirrored
const W = PAGE.w - PAGE.inner - PAGE.outer; // the type area's width: 177 mm
const SPINE = 12; // mm: the room on the left of the areas where the chart's spine runs down
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const line = (more) => ({ kind: 'connector', color: col('green'), width: pt(1.2), radius: mm(2.5),
...more });
// #region answer: a module per area of the town hall, stacked by the page's modules group
// The page template draws the head of the chart and a group that takes the page's modules;
// the module draws one area: its box, a grid of its services and the lines between them.
const area = {
id: 'area', role: 'module',
frames: [
{ name: 'concejalia', style: 'area', width: mm(84), placement: at('container', 'top-left') },
{ name: 'servicio', style: 'servicio' }, // repeated: one per :::frame{slot="servicio"}
],
groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#窓口を二列にする
columns: 2にすると、各部の窓口が一行に二つずつ並びます。ツリーは横棒を一行目の上にしか引かないので、窓口が二つか四つの部に限って使ってください。
- groups: [{ name: 'servicios', kind: 'grid', columns: 3, columnGap: mm(4), rowGap: mm(4),
+ groups: [{ name: 'servicios', kind: 'grid', columns: 2, columnGap: mm(4), rowGap: mm(4),#幹の角を丸める
radiusは折れ線とツリーの曲がり角を丸めます。ヘルパーのline()はどのコネクタにも2.5mmを与えます。radius: mm(0)にすると、組織図は直角になります。
よくあるつまずき
- グリッドの項目はそれぞれ独立した囲いです。写真カードの文字は、その
:::frame{slot="pie"}をカードの:::frame{slot="foto"}の中に書いたときだけ、そのカードのものになります。 - ページの枠を名指しするモジュールのコネクタは、その枠があるページにしか描かれません。ほかのページでは省かれ、再組版のログに
connectorDroppedと出ます。 - つながったタブは囲みの縁に乗り、囲みの塗りを受け継ぎます。タブの文字には濃い
colorを与えてください。淡い囲みの上の白い文字は読めません。
クレジット
- 本文
- A guide to the council services of Valdeloma, an invented town, written for this recipe in Spanish and English · Postext Cookbook · CC BY 4.0
- 画像
- The reading room of a town library, in watercolour. · Generated With Diffusion Models · オリジナル
- Children with violins and a piano at a music school, in watercolour. · Generated With Diffusion Models · オリジナル
- Older people playing cards and painting at a day centre, in watercolour. · Generated With Diffusion Models · オリジナル
- A covered pool under a timber roof, in watercolour. · Generated With Diffusion Models · オリジナル
- Children playing basketball in a sports hall, in watercolour. · Generated With Diffusion Models · オリジナル
- A Saturday market under the arcades of a town square, in watercolour. · Generated With Diffusion Models · オリジナル
- フォント
- Public Sans (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1)


