Granvas
Write thoughts. See structure.
文章を書くように、思考のグラフを書く。
- 🖥 デモ(インストール不要): https://granvas.vercel.app/
- 📘 公式ドキュメント(日本語): https://dayaa-arch.github.io/granvas/
- 💻 GitHub: https://github.com/dayaa-arch/granvas (MIT License)
1. 作ったもの
Granvas は、テキストエディタに文章を書いていくと、その一部が自動的に意味のグラフとして右ペインに立ち上がってくる、ブラウザ完結のビジュアル思考エディタです。
左に書いたこれが、
@layout flow TB
# 新製品のアイデア
顧客の情報がどこにあるかわからない、という話が何度も出てくる。
[problem @scattered] 顧客情報が散らばっている
-> [cause] Excelが分散している
-> [cause] チームの知識が属人化している
[idea @unify] AIがメモと構造を統合する
[todo @interview] ユーザーインタビュー
@unify -> @scattered : solves
{Discovery}
@scattered
@interview
右で 5ノード・3リレーション・1グループのグラフになります。# 新製品のアイデア や「顧客の情報が〜」といった普通の文章はそのまま文章として残り、グラフには投影されません。

アカウント不要、サーバーへの送信ゼロ、ブラウザを開いた瞬間から使えます。現在 v0.1 Release Candidate で、Phase 0〜13 まで完了しています。
2. なぜ作ったか
書くのは速い。でも、書いた文章から「課題 → 原因 → 打ち手」の関係を俯瞰するのは難しい。
かといって作図ツールを開くと、今度は配置と装飾の作業が始まります。この矩形をどこに置くか、線をどう曲げるか。思考ではなく操作をしている時間が増えていく。
Mermaid や D2 のようなテキストベースの作図言語はこの問題をかなり解決しました。でも、まだ 2 つ残っていると感じていました。
2.1 「書きかけ」で図が消える
Mermaid は ```mermaid フェンスの中が壊れると、図全体が出なくなります。書きながら考えるツールとしては、これは致命的です。行を打っている途中は必ず不完全だからです。
Granvas はフェンスを持ちません。散文と記法が同じドキュメントに混在し、記法として解釈できない行は「ただの文章」として扱われます。1 行壊れても、他の valid な構造は消えません。
2.2 「未確定」を表現できる記法がない
これがもっと本質的でした。
既存の軽量記法はすべて 確定した構造を描く言語です。Mermaid も D2 も Argdown も、「これは仮説である」「この因果はまだ検証していない」「この案は棄却した」を表現する手段を持っていません。
でも、思考の途中には必ず未確定が含まれます。むしろ未確定こそが思考の本体です。
Granvas は、確信度(certainty)を記法の一級市民として持ちます。
[problem @churn] 解約が増えている
!-> [cause] オンボーディングが長すぎる
?-> [?hypothesis @price] 価格が高い
~-> [~cause] UIが古い
[!idea @onboarding] 初回セットアップを3ステップに削る
[~idea @discount] 値下げする
@onboarding -> @churn : solves
@price ?-> @churn : maybe
| マーカー | 意味 | 表示 |
|---|---|---|
| (なし) | 未指定 | 通常 |
? |
未確定 — 仮説、未検証 | 破線 + ? バッジ |
! |
確定 — 検証済み、採用 | 実線太め + ✓ バッジ |
~ |
棄却 — 見送ったが記録は残す | 打ち消し線 + グレーアウト |
設計の中心は「棄却したノードをグラフから消さない」ことです。~ を付けたノードは打ち消し線で描画され、キャンバス上に残り続けます。「何を検討して何を捨てたか」が構造として残ることが、この機能の目的そのものです。本当に消したいならテキストからその行を消せばいい。~ は「消さずに棄却を記録する」ための記法です。
確信度は Type と直交する軸として設計しました。[hypothesis] のような Type 名に埋め込む案もありましたが、それだと hypothesis-problem / hypothesis-idea のような組み合わせ爆発を起こします。前置マーカーにしたことで [?problem] [?cause] [?idea] がすべて自然に書けます。
なお ? ! ~ は元の文法で type 名の先頭文字として invalid だったため、既存の .granvas ファイルは 1 文字も変えずに同じ構造として解析されます。Phase 3 の全 fixture が無改変で通ることを後方互換の証明としています(ADR-0003)。
3. 一番難しかった設計 ── グラフを触ると、テキストが書き換わる
「テキストから図を作る」ツールは山ほどあります。でも、図を見て気づいたことを図の上で直せるものはほとんどない。結局テキストに戻って該当箇所を探すことになります。
Granvas はグラフ側からの編集をサポートしています。ラベル変更、Type 変更、確信度変更、ノード作成、エッジ接続、削除、ドラッグでの親子関係変更、グループ所属変更。すべてテキストへの書き換えとして反映され、Cmd+Z 1 回で元に戻ります。
ここに、実装上のかなり強い制約がありました。
3.1 シリアライズが使えない
Granvas のドキュメントには散文が混在していて、散文はグラフに投影されていません。つまり グラフからテキスト全文を再生成することは原理的に不可能です。やった瞬間にユーザーが書いた文章が消し飛びます。
そもそも書式(空行の入れ方、宣言の順序、Group の位置)もすべて正規化されてしまい、「テキストが正本」という体験自体が成立しません。
したがって選択肢は 1 つしかありません。現在の source に対する最小の編集列を計算する。
3.2 編集規則を Notation ドメインの純関数として持つ
「A と B を接続する」という一見単純な操作は、実際には次の文法知識を必要とします。
- 両端の Node に
@idがあるか。無ければ ID 規則を満たす形でどこに挿入するか - Cross Relation 行をドキュメントのどこへ挿入するか
- 挿入結果が Group scope や Nested Relation の parent stack を壊さないか
これらはグラフの知識ではなく、記法そのものの知識です。そこで (source, parseResult, command) → 編集列 という純関数を Notation Context の domain に置きました(ADR-0002)。
export type SourceEdit = Readonly<{ from: number; to: number; insert: string }>
export type SourceEditPlan =
| Readonly<{ type: 'applicable'; edits: readonly SourceEdit[]; caretAnchor?: number }>
| Readonly<{ type: 'rejected'; reason: NotationEditRejection }>
規則はこうです。
-
editsはfrom昇順で、範囲が重複しないことを保証する。planConnectNodesは「2 つの宣言行への@id挿入」と「Cross Relation 行の追記」で 3 箇所を同時に触るため、この保証が必須になる - 実行できない操作は例外ではなく
rejectedとして理由付きで返す。循環する親付け替えなどが該当する - React / CodeMirror / React Flow / DOM / browser API を一切参照しない
実際の planConnectNodes はこんな形です。
export function planConnectNodes(
source: string,
parseResult: NotationParseResult,
input: Readonly<{
sourceNodeKey: string
targetNodeKey: string
label?: string
certainty?: NotationCertainty
}>,
): SourceEditPlan {
const sourceNode = nodeForCurrentSource(source, parseResult, input.sourceNodeKey)
const targetNode = nodeForCurrentSource(source, parseResult, input.targetNodeKey)
if (!sourceNode || !targetNode) {
return reject('unknown-target', 'An endpoint Node is not present in the current source.')
}
// ... @id が無ければ採番して宣言行へ挿入する edit を積む
const edits: SourceEdit[] = []
if (sourceReference.edit) edits.push(sourceReference.edit)
if (targetReference.edit && targetReference.edit !== sourceReference.edit) {
edits.push(targetReference.edit)
}
const operator = `${certaintyMarkers[certainty]}->`
const relation = `@${sourceReference.id!} ${operator} @${targetReference.id!}${
label ? ` : ${label}` : ''
}`
edits.push({ from: source.length, to: source.length, insert: blockInsertion(...) })
return applicable(sourceNode.sourceRange.from, edits)
}
3.3 この設計で得られたもの
編集規則が executable specification になりました。 完全な純関数なので、Parser のテストと同じ方法で検証できます。
最も強い契約は round-trip です。
- plan を適用した source を再 parse すると、意図した構造になっている
- 散文と無関係な行が一切変化しない
この 2 つを全操作についてテストで保証しています。全文再生成の経路がコード上に存在しないので、2 が構造的に守られます。
副次的な効果として、Undo が正しく動きます。CodeMirror は複数レンジの changes を 1 トランザクションとして扱えるため、グラフ操作もテキスト編集も同じ history に載ります。ドラッグでノードの親を変えて、Cmd+Z で戻る。
3.4 引き受けたコスト
正直に書いておくと、コストもあります。
グラフ編集の前には pending なテキスト更新を必ず flush する必要があります。入力は 120ms debounce しているので、これを怠ると古い parseResult の offset に対してパッチを計算し、ずれた位置を書き換えてしまう。設計上の必然的な帰結なので、実装時の明示的な要件としてドキュメントに落としました。
また削除は連鎖(参照している Cross Relation 行、Group 参照行、Nested Relation の子孫)を扱うため、素朴なシリアライズより実装量がずっと多くなります。だから削除には影響プレビューを付けています。
4. ドラッグは「配置の操作」ではなく「意味の操作」
図を見ていると、ノードを掴んで動かしたくなります。これは最も自然に出てくる操作要求です。
でも Granvas の中核原則はこの 2 つで、どちらもドラッグと正面衝突します。
- Text is the source of truth. グラフの座標・見た目は正本ではない
- 記法は「意味と関係を書く言語」であり、
x: 120のような見た目の記述を導入しない
決めたのはこうです。ドラッグはできる。座標は一切永続化しない。
| ドロップ先 | 解釈される意味 | テキストへの反映 |
|---|---|---|
| 別 Node の上 | その Node を親にする | 行を新しい親の直下へ移動し、indent を 親indent + 2 に再計算。トップレベル宣言だった場合は -> を前置 |
| Group overlay の内側 | その Group のメンバーにする |
@id が無ければ自動採番して宣言行へ付与し、Group ブロックへ @id 行を追加 |
| 空白 | 親子関係を解除する |
-> を剥がし、indent 0 の宣言行へ戻す |
自分の子孫を親にする操作(循環)は拒否され、理由が UI に示されます。テキストは変更しません。
この決定には避けられない体験上のコストがあります。ドロップした瞬間、ノードは指を離した位置ではなく自動レイアウトが決めた位置へ移動する。仕様どおりの挙動ですが、何も手当てしないと「掴んで動かしたのに戻った」と読まれます。
なので、ドラッグ中にドロップ先の親候補をハイライトし、確定後は新しい配置へアニメーション遷移させることを実装要件にしました。これは装飾ではなく、決定を成立させるための必須要素です(ADR-0001)。
座標を保存する案(draw.io 方式)も検討しましたが、保存した瞬間に .granvas は「テキスト」ではなく「テキスト+レイアウト状態」になります。テキストを手で編集したときに座標が意味と食い違う(ノードを削除したのに座標が残る、順序を変えたのに配置が古い)という不整合を恒久的に抱え込むため、採用しませんでした。
5. アーキテクチャ
client-only の React SPA を、DDD + Layered Architecture + Modular Monolith で構成しています。
Presentation → Application → Domain
↑
Infrastructure ────────┘
5 つの Bounded Context に分割しています。
| Context | 責務 |
|---|---|
| Document | active source、revision、dirty ライフサイクル |
| Notation | Parser、確信度、diagnostics、SourceRange / spans、編集規則 |
| Graph | 意味グラフ、自動レイアウト、export scene |
| Transfer | Project Import と多形式 Download |
| Workspace | 公開契約を通じた協調 |
Context の内部は private です。外部からは src/modules/<context>/index.ts 経由でしか触れません。
5.1 境界を ESLint とテストで強制する
「境界を守る」をドキュメントに書くだけだと必ず腐るので、カスタム ESLint ルール granvas/boundaries として実装し、さらにそれをテストしています。
it('rejects framework imports and browser globals from Domain', async () => {
const messages = await boundaryMessages(
'src/modules/document/domain/example.ts',
"import type { ReactNode } from 'react'\nexport const example: ReactNode = window.name\n",
)
expect(messages.map(({ ruleId }) => ruleId)).toEqual([
'granvas/boundaries',
'no-restricted-globals',
])
})
it('rejects cross-context imports outside Workspace', async () => {
const messages = await boundaryMessages(
'src/modules/graph/application/example.ts',
"import type { DocumentContract } from '@/modules/document'\nexport type Example = DocumentContract\n",
)
expect(messages[0]?.messageId).toBe('crossContext')
})
ESLint を lintText でテストコードから直接呼び、違反するコードを書いて「ちゃんと怒られること」を検証しています。Domain への React import、レイヤーの逆流、Workspace 以外からの Context 跨ぎ、bootstrap 以外からの deep import ── これらは CI で落ちます。
5.2 技術スタック
| 領域 | 技術 |
|---|---|
| 言語 | TypeScript 6(strict) |
| UI | React 19 |
| ビルド | Vite 8 |
| パッケージマネージャ | Bun 1.3.x |
| エディタ | CodeMirror 6 |
| グラフ描画 | React Flow (@xyflow/react 12) |
| レイアウト | Dagre (@dagrejs/dagre 3) |
| PDF 生成 |
pdf-lib 1.17(PDF 選択時のみ dynamic import) |
| Unit / Component | Vitest 4 + React Testing Library |
| E2E | Playwright(Chromium / Firefox / WebKit) |
| ホスティング | Vercel 静的デプロイ(server function なし) |
| ドキュメント | GitHub Pages |
ライブラリはすべて infrastructure か presentation の 1 箇所に閉じ込めています。React Flow の Node 型、Dagre の Graph 型、EditorView、FileSystemHandle は public contract に一切出てきません。
6. リアルタイム投影の並行制御
「入力するたびにパースしてレイアウトして描画する」は、素朴に書くと前の revision の結果が後から到着して混ざります。
documentRevision を source 更新ごとに単調増加させ、ParseResult / ThoughtGraph / PositionedGraph / SourceMap / Diagnostics のすべてが revision を持ちます。
- 新しい revision の開始時に、古いレイアウトを framework-neutral な
CancellationSignalでキャンセルする - 完了しても current revision と一致しない結果は破棄する
- Graph / SourceMap / Diagnostics を異なる revision から合成しない
CancellationSignal は Granvas 独自の型で、adapter が内部で AbortController や Worker message へ変換します。Application 層に AbortSignal が漏れません。
Dagre は Web Worker で回します。adapter はリクエストごとに Worker を起こし、requestId で応答を照合し、キャンセル・失敗・成功のいずれでも必ず terminate() する構造にしています。
const handleMessage: MessageListener = ({ data }) => {
if (data.requestId !== requestId) return
if (data.status === 'failure') {
finish(() => reject(new GraphApplicationError('layout-failed', data.message)))
return
}
finish(() => resolve(data.output))
}
7. エクスポート ── DOM スナップショットを撮らない
SVG / PNG / PDF の出力があります。全部「今画面に見えているところ」ではなく full graph が出ます。
ポイントは、DOM のスナップショットを撮っていないことです。入力は GraphExportSceneDto という単一の scene DTO で、そこから 3 形式を生成します。
- SVG: scene から直接組み立てる。untrusted なラベルは各 sink で escape
- PNG: 自己完結 SVG を white background の Canvas へ描画。2x 基本、8192px 上限(縮小時は notice を返す)
-
PDF: 同じ Canvas PNG を
pdf-libで single-page に埋め込む。ページサイズは graph bounds に合わせる(1 CSS px = 0.75pt)
確信度は 色だけに依存せず、線種・太さ・バッジ・打ち消し線で表現しています(WCAG 2.2 AA 適合目標)。SVG の各要素には aria-label も入れています。
export function escapeXmlText(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
// ...
}
Import したテキストと生成ラベルは常に untrusted input として扱っています。
8. ユーザーがファイルを所有する
ブラウザの draw.io と同じ考え方です。永続化は明示的なユーザー操作であって、隠れたブラウザストレージではありません。
| 形式 | 用途 | Granvas で再編集 |
|---|---|---|
.granvas |
後で編集を続けるための UTF-8 プロジェクトソース | ✅ |
| SVG | ドキュメント・Web 向けのスケーラブル成果物 | ❌ |
| PNG | ラスタ成果物 | ❌ |
| 共有・印刷向けの 1 ページ成果物 | ❌ |
.granvas は active source だけを持ちます。座標も派生データも入りません。だから同じテキストから常に同じ意味構造と同じ配置が再生成されます。
v0.1 は localStorage / IndexedDB への自動保存をしません。未ダウンロードの変更には dirty 表示が出て、離脱時に警告します。
9. セキュリティとプライバシー
production asset のロード後、outbound request は 0 です。
- テレメトリなし、リモート API なし、クラウドストレージなし、アカウントなし
- notation を実行しない。
evalと動的コード実行を禁止 - source 由来の文字列に
dangerouslySetInnerHTMLを使わない - ファイル名から path separator、制御文字、予約文字を除去
Vercel には CSP を設定しています。
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:; font-src 'self';
connect-src 'none'; object-src 'none'; base-uri 'none';
frame-ancestors 'none'; form-action 'none'
connect-src 'none' です。ネットワークに出る経路自体がありません。
さらに、リリースビルドの中身をスクリプトで検査しています(bun run release:verify)。
- source map が混入していないか
- 資格情報らしき文字列(private key、
SUPABASE_*、AKIA...、gh[pousr]_...、sk_live_...)が無いか - analytics / tracking ホスト(
google-analytics.com、googletagmanager.com、plausible.io、segment.comなど)への参照が無いか - 許可リスト外の URL が埋まっていないか
これが CI の必須ステップに入っています。「トラッキングしていません」を主張ではなく検証にしています。
10. 品質ゲート
CI(.github/workflows/quality.yml)は PR と main push で以下を回します。デプロイ job も repository write 権限も credential も持ちません。
bun install --frozen-lockfile
bun run typecheck
bun run lint
bun run test:coverage
bun run build
bun run release:verify
bun run docs:build
bun run licenses:verify
bun audit --audit-level=high
加えて、Chromium / Firefox / WebKit の 3 ブラウザ E2E をマトリクスで、性能予算を別 job で回しています。
手元の実測値
執筆時点で手元(Apple Silicon Mac)で実行した結果です。基準 fixture は 500 行 / 200 ノード / 300 エッジ / 10 グループ。
$ bun run test:run
Test Files 27 passed (27)
Tests 151 passed (151)
Duration 11.52s
$ bun run test:performance
PERF parser_p95_ms=1.70 (予算 < 50ms)
PERF source_edit_plan_p95_ms=0.19 (予算 < 20ms)
PERF layout_p95_ms=168.37 (予算 < 200ms)
Test Files 2 passed (2)
Tests 4 passed (4)
Parser は予算の 30 分の 1、SourceEditPlan の生成は 100 分の 1 に収まっています。一方 Dagre レイアウトは 168ms で、200ms 予算に対して余裕が少ない。ここが次のボトルネックだと分かっているので、Worker 化済みの現状からさらに手を入れる余地として記録しています。
fixture のサイズが期待どおりであること(500/200/300/10 ちょうど、diagnostics ゼロ)自体もテストしていて、fixture が痩せて性能テストが空振りするのを防いでいます。
11. 記法の全体像
現在の Granvas Notation v0.2 で書けるもの。
@layout flow TB # または LR
[type] ラベル # Node
[type @id] ラベル # 明示 ID 付き Node
[?type] ラベル # 確信度マーカー(? ! ~)
[problem] 親
-> [cause] 子 # Nested Relation(indent 2 スペース)
-> [cause] 孫
?-> [cause] 未確定の子 # 確信度付き Relation
@id1 -> @id2 : ラベル # Cross Relation
@id1 ~-> @id2 # 棄却された関係
{グループ名} # Group
[idea] グループ内で宣言
@id # 既存ノードを参照して所属させる
これは普通の文章です。グラフには出ません。
診断コードは GNV001〜GNV014 の 14 種類あり、それぞれ info / warning / error のレベルと SourceRange を持ちます。重要なのは、エラーが出ても他の valid な構造が消えないことです。
GNV001_INCOMPLETE_NODE info 書きかけ(エラーではない)
GNV002_EMPTY_LABEL error
GNV003_INVALID_ID error
GNV004_DUPLICATE_ID error
GNV005_UNRESOLVED_REFERENCE warning
GNV006_INVALID_INDENT warning
GNV007_TAB_INDENT warning
GNV008_ORPHAN_RELATION warning
GNV009_INVALID_LAYOUT warning
GNV010_DUPLICATE_LAYOUT warning
GNV011_NESTED_GROUP_UNSUPPORTED warning
GNV012_EMPTY_RELATION_LABEL warning
GNV013_EMPTY_GROUP_NAME error
GNV014_INVALID_CERTAINTY_MARKER error
GNV001_INCOMPLETE_NODE が info レベルなのがこのツールの性格を表しています。[prob まで打った状態はエラーではなく、単に「まだ書いている途中」です。
12. v0.1 でやらないこと
意図的にスコープ外にしているものを明示しておきます。
- 自由作図、ノード座標の保存、手動配置
- グラフ上での散文の編集(散文は Text ペインでのみ)
- localStorage / IndexedDB への自動保存
- 複数プロジェクト管理、フォルダ、検索、バックリンク
- アカウント、認証、クラウド同期、共同編集、backend API
- AI 生成、プラグイン、モバイル / デスクトップアプリ
- 完全な Markdown 互換
- SVG / PNG / PDF からの再編集 Import
デスクトップ前提です(最小 960px、推奨 1280px 以上)。
将来認証を入れる場合、プロバイダは Supabase Auth に決定済みですが、v0.1 では SDK も環境変数も UI も一切入れていません。identity Context を新設し、provider port を application、Supabase adapter を infrastructure に置く、という配置だけ決めてあります。
13. 触ってみてください
インストール不要のデモ: https://granvas.vercel.app/
開いた瞬間にサンプルドキュメントが入っているので、左ペインを書き換えると右のグラフが動きます。日本語 UI です。
日本語の公式ドキュメント: https://dayaa-arch.github.io/granvas/
記法の全体、確信度の使い方、グラフ側編集、ファイルワークフローを日本語で説明しています。
ローカルで動かす
git clone https://github.com/dayaa-arch/granvas.git
cd granvas
bun install
bun run dev
| 目的 | コマンド |
|---|---|
| 開発サーバー | bun run dev |
| 型チェック | bun run typecheck |
| ESLint + アーキテクチャ境界 | bun run lint |
| ユニットテスト | bun run test:run |
| カバレッジ | bun run test:coverage |
| 3 ブラウザ E2E | bun run e2e |
| 本番ビルド | bun run build |
| 公式ドキュメントのビルド | bun run docs:build |
Playwright のブラウザバイナリが未インストールなら bunx playwright install を先に。
14. コントリビュート歓迎です
MIT License です。公開しながら作っているので、大きな変更の前には Issue で相談してもらえると、前提のすれ違いを防げて助かります。
- 仕様・設計は
docs/に置いています。統合仕様書GRANVAS_SPEC_v0.1.mdが実装判断の基準です - 設計上の分岐点は ADR に 6 本記録しています。「なぜそうしたか」だけでなく「何を却下したか」も書いてあります
- Issue: https://github.com/dayaa-arch/granvas/issues
特に欲しいフィードバック:
- 記法の書き味。 実際に思考メモを書いてみて、書きにくかった箇所
- 確信度マーカーが役に立つ場面/役に立たない場面
- グラフ側編集がテキストを壊すケース。 round-trip テストは書いていますが、想定外の入力は必ずあります
おわりに
Granvas の仮説は 1 つです。
文章を書く行為と、思考構造を見る行為を、一つの連続した体験にできるか。
Markdown が文書構造を軽量な記法で表現したように、思考の構造を軽量な記法で表現できないか。しかも、未確定なまま。
もしこの問題意識が刺さったら、デモを開いて 3 分だけ触ってみてください。そして GitHub で ⭐ をもらえると、とても励みになります。
- 🖥 デモ: https://granvas.vercel.app/
- 📘 ドキュメント: https://dayaa-arch.github.io/granvas/
- 💻 GitHub: https://github.com/dayaa-arch/granvas