Skip to content

方眼紙作図 ​

**「線を引く」は格子の点から点へドラッグします。「頂点を動かす」**は白い丸をドラッグすると、そこに集まっている線がすべて追従します。そのあと、元に戻すを1回押してみてください。

1操作 = 履歴1件 ​

このアプリを置いた理由はここです。4本の線が集まる頂点をドラッグすると、4つのシェイプ(位置・サイズ・両端の比率)が書き換わります。それでも取り消せる単位は1つでなければなりません。ユーザーがした動作は1つだからです。

この4つを pointermove のたびに書き込むのは、二重に間違っています。イミュータブルな文書を毎秒60回作り直すことになり、履歴も60件になります。一時状態(ephemeral)はまさにこのためにあります。 変更は毎フレーム書き換えてもコストのかからないオーバーレイのマップに入り、commitEphemeral() が最終状態だけを1件の履歴として文書に畳み込みます。

ts
onPointerMove(event: HcPointerEvent): void {
  const changes = new Map<ShapeId, Partial<AnyShape>>()
  for (const segment of pending.incident) {
    const box = segmentBox(this.snap(event.world), segment.anchor)
    changes.set(segment.id, { x: box.x, y: box.y, width: box.width, height: box.height, props: … })
  }
  this.editor.setEphemeral(changes)      // 毎フレーム繰り返しても安い
}

onPointerUp(): void {
  if (pending.moved) this.editor.commitEphemeral()   // 何本集まっていても履歴1件
}

ヒットテスト・境界取得・描画はいずれも一時状態を通して読むので、ドラッグ中に値が食い違うことはありません。コンセプト →

線が端点をどこに持っているか ​

line は start と end を座標ではなく自分のボックスに対する比率で持っています。これがあるおかげで、線の端点は通常のリサイズ機構だけで動き、どこにも特別扱いが要りません。そして線を1本置く作業は、「ボックスを求めて、その中のどこに両端が来るかを計算する」だけになります。

ts
function segmentBox(from: Vec, to: Vec): SegmentBox {
  const x = Math.min(from.x, to.x)
  const y = Math.min(from.y, to.y)
  // 完全な水平・垂直の線は、そのままではサイズ 0 のボックスになってしまう。
  // 選択もリサイズもできなくなる。
  const width = Math.max(Math.abs(to.x - from.x), 1)
  const height = Math.max(Math.abs(to.y - from.y), 1)
  return {
    x, y, width, height,
    start: { x: (from.x - x) / width, y: (from.y - y) / height },
    end: { x: (to.x - x) / width, y: (to.y - y) / height },
  }
}

文書には「頂点」という概念自体がありません。 頂点とは2本の線がたまたま共有している座標にすぎず、アプリ側が端点を突き合わせて復元しています。これをライブラリに入れていないのは意図的です。シェイプどうしの結びつきはアプリケーションのモデルであって、キャンバスエンジンのモデルではありません。

格子を1要素で描く ​

方眼は、オーバーレイの中に置かれた <div> 1つです。オーバーレイにはビューポート変換がすでに掛かっているので、方眼は絵と一緒にパン・ズームします。 図形数に比例して増えるフレームごとの処理はありません(不変条件3)。

点の半径は --hc-zoom で割っています。リサイズハンドルのサイズと同じ理屈です。絵は拡大されるが、紙の目盛りは拡大されないからです。

css
.hc-app-grid {
  background-image: radial-gradient(
    circle,
    var(--hc-app-grid-ink) calc(1px / var(--hc-zoom)),
    transparent 0
  );
}

1マスが画面上で5px を切ると、方眼は読めるより細かくなるので単に描きません。装飾ガイド →

頂点マーカーの個数を有界に保つ ​

既存の頂点を示す白い丸はアプリ側の DOM です。そして文書のサイズに比例して増える DOM こそ、このライブラリが避けるために作られたものです。マーカーはプール化・上限つきで、editor.getVisibleShapeIds()(レンダラが使うカリング結果そのもの)からのみ作られます。したがって個数を決めるのは表示範囲であって、図面の規模ではありません。

ts
for (const id of editor.getVisibleShapeIds()) {
  const shape = editor.getResolvedShape(id)
  if (shape?.type !== 'line') continue
  for (const point of endpoints(shape)) {
    if (points.size >= MAX_MARKERS) break
    points.set(key(point), point)
  }
}

描きながらステータス行のオーバーレイ DOM ノード数を見てみてください。性能ガイド →

3つのモード、3つのツール ​

線を引く・頂点を動かす・消すは、いずれも同じポインタイベントを取り合います。これを条件分岐で解決すると、誰も安全に変更できないコードになります。そこでそれぞれをツールにします。 アプリ定義のツールも、組み込みと同じ API で登録します。

ts
editor.tools.register('draw-segment', (e) => new SegmentTool(e, settings, 'draw'))
editor.tools.register('move-vertex', (e) => new SegmentTool(e, settings, 'vertex'))
editor.tools.register('erase', (e) => new EraseTool(e))
editor.tools.setCurrent('draw-segment')

線を引くのと頂点を動かすのは実装を共有していますが、モードは共有しません。 ここは重要です。このアプリの初期版は、押した位置で自動的に判断していました(既存の頂点の近くなら移動、そうでなければ描画)。説明としては通りますが、実際には使えません。 「既存の頂点の近く」は押下のほとんどです。線はそこから始まるからです。結果として判断がしばしば外れ、線を引きたいのに頂点が動きます。ツールを分ければ、判断そのものが不要になります。

選択ツールはそもそも置いていません。**自由なドラッグは方眼紙のための操作ではなく、**用意しても線を格子から外せるようになるだけです。

settings は参照で保持しているので、目盛りとインクの操作は再登録せずに動作中のツールへ反映されます。ツールガイド →

ソース ​

アプリ本体
ts
import type {
  AnyShape,
  Editor as EditorInstance,
  HcPointerEvent,
  LineShape,
  ShapeId,
  Tool,
  Vec,
} from '@headless-canvas/core'
import { Editor } from '@headless-canvas/core'
import { createDefaultControls } from '@headless-canvas/ui'
import '@headless-canvas/ui/styles.css'
import type { Demo } from '../demos/types'
import { t } from '../demos/types'
import { button, colorPicker, slider } from '../demos/ui'
import { appScaffold, download, EraseTool, radio } from './chrome'

/**
 * Drafting on squared paper: draw a segment from lattice point to lattice
 * point, erase one, or grab a vertex and drag it — everything meeting there
 * follows, and one undo puts it all back.
 *
 * That last sentence is the whole reason this application is here. Moving a
 * vertex rewrites every segment touching it, which is many writes for one
 * gesture, and getting it to behave as one gesture is what the ephemeral layer
 * and `commitEphemeral` are for (spec §5.2.4).
 */

/** How close, in screen pixels, counts as grabbing an existing vertex. */
const GRAB_PX = 10

/** Vertex markers are pooled and capped; the viewport is the real bound. */
const MAX_MARKERS = 240

/** Below this the lattice is denser than it is legible, so it is not drawn. */
const MIN_GRID_PX = 5

interface WireSettings {
  pitch: number
  color: string
  width: number
}

/** A lattice point, as a string, so vertices can be compared and deduplicated. */
const key = (point: Vec): string => `${Math.round(point.x)},${Math.round(point.y)}`

/** The two endpoints of a segment, in world coordinates. */
function endpoints(shape: LineShape): [Vec, Vec] {
  const { start, end } = shape.props
  return [
    { x: shape.x + start.x * shape.width, y: shape.y + start.y * shape.height },
    { x: shape.x + end.x * shape.width, y: shape.y + end.y * shape.height },
  ]
}

interface SegmentBox {
  x: number
  y: number
  width: number
  height: number
  start: Vec
  end: Vec
}

/**
 * A segment as a shape box plus endpoint ratios.
 *
 * `line` stores its endpoints as fractions of its own box rather than as
 * coordinates, which is what lets the ordinary resize machinery move them with
 * no special case. Placing one is therefore a matter of finding the box and
 * working out where in it the two ends fall.
 */
function segmentBox(from: Vec, to: Vec): SegmentBox {
  const x = Math.min(from.x, to.x)
  const y = Math.min(from.y, to.y)
  // A perfectly horizontal or vertical segment would otherwise get a
  // zero-sized box: impossible to select and impossible to resize.
  const width = Math.max(Math.abs(to.x - from.x), 1)
  const height = Math.max(Math.abs(to.y - from.y), 1)
  return {
    x,
    y,
    width,
    height,
    start: { x: (from.x - x) / width, y: (from.y - y) / height },
    end: { x: (to.x - x) / width, y: (to.y - y) / height },
  }
}

/** Every segment on the page. Small enough that a scan beats an index. */
function segmentsOf(editor: EditorInstance): LineShape[] {
  const out: LineShape[] = []
  for (const id of editor.getChildren(null)) {
    const shape = editor.getShape(id)
    if (shape?.type === 'line') out.push(shape)
  }
  return out
}

/** One end of one segment that meets the vertex being dragged. */
interface Incident {
  id: ShapeId
  props: LineShape['props']
  /** The end that stays put. */
  anchor: Vec
  /** Which end of this segment is the one being moved. */
  moving: 'start' | 'end'
}

type Pending =
  | { kind: 'draw'; from: Vec; draft: ShapeId }
  | { kind: 'vertex'; incident: readonly Incident[]; moved: boolean }

/**
 * Drawing a segment and moving a vertex are separate tools rather than one
 * tool deciding by proximity.
 *
 * Deciding on the fly reads well in a description and is unusable in practice:
 * every press near an existing vertex — which is most presses, since that is
 * where segments start — has to guess which of two things was meant, and the
 * guess is wrong often enough to be maddening. Two tools cannot guess wrong.
 * That is what the tool abstraction is for (spec §5.8.2).
 */
type SegmentMode = 'draw' | 'vertex'

const TOOL_DRAW = 'draw-segment'
const TOOL_VERTEX = 'move-vertex'

class SegmentTool implements Tool {
  readonly id: string

  private pending: Pending | null = null

  constructor(
    private readonly editor: EditorInstance,
    private readonly settings: WireSettings,
    private readonly mode: SegmentMode,
  ) {
    this.id = mode === 'draw' ? TOOL_DRAW : TOOL_VERTEX
  }

  onExit(): void {
    this.abandon()
  }

  onCancel(): void {
    this.abandon()
  }

  onPointerDown(event: HcPointerEvent): void {
    if (event.button !== 0) return

    if (this.mode === 'vertex') {
      const incident = this.grabVertex(event)
      // Nothing to grab: the press does nothing rather than quietly drawing.
      if (!incident) return
      this.editor.tools.setState('dragging')
      this.pending = { kind: 'vertex', incident, moved: false }
      return
    }

    this.editor.tools.setState('dragging')
    const from = this.snap(event.world)
    // Not a user action yet. The draft is there to be looked at while the
    // pointer is down, and is replaced on release by the segment that is —
    // so it is created outside the history entirely.
    const draft = this.editor.transact(
      () => this.editor.createShape({ type: 'line', ...this.geometry(from, from) }),
      { addToHistory: false },
    )
    this.pending = { kind: 'draw', from, draft }
  }

  onPointerMove(event: HcPointerEvent): void {
    const pending = this.pending
    if (!pending) return
    const to = this.snap(event.world)

    if (pending.kind === 'draw') {
      this.editor.setEphemeral(new Map([[pending.draft, this.geometry(pending.from, to)]]))
      return
    }

    pending.moved = true
    const changes = new Map<ShapeId, Partial<AnyShape>>()
    for (const segment of pending.incident) {
      const box = segmentBox(to, segment.anchor)
      changes.set(segment.id, {
        x: box.x,
        y: box.y,
        width: box.width,
        height: box.height,
        // The ephemeral overlay is merged one level deep, so props go in whole
        // rather than as a patch.
        props: {
          ...segment.props,
          start: segment.moving === 'start' ? box.start : box.end,
          end: segment.moving === 'start' ? box.end : box.start,
        },
      })
    }
    this.editor.setEphemeral(changes)
  }

  onPointerUp(event: HcPointerEvent): void {
    const pending = this.pending
    this.pending = null
    if (this.editor.tools.state === 'dragging') this.editor.tools.setState('idle')
    if (!pending) return

    if (pending.kind === 'vertex') {
      // However many segments met at that vertex, the drag was one gesture and
      // becomes one entry in the history.
      if (pending.moved) this.editor.commitEphemeral()
      else this.editor.clearEphemeral()
      return
    }

    const to = this.snap(event.world)
    this.editor.clearEphemeral()
    this.editor.transact(() => this.editor.deleteShapes([pending.draft]), { addToHistory: false })

    if (key(to) === key(pending.from)) return
    if (this.alreadyDrawn(pending.from, to)) return
    this.editor.createShape({ type: 'line', ...this.geometry(pending.from, to) })
  }

  /**
   * The segments meeting the vertex under the pointer, or null when there is
   * no vertex there.
   */
  private grabVertex(event: HcPointerEvent): Incident[] | null {
    const threshold = GRAB_PX / this.editor.viewport.camera.z
    const segments = segmentsOf(this.editor)

    let vertex: string | null = null
    for (const shape of segments) {
      for (const point of endpoints(shape)) {
        if (Math.hypot(point.x - event.world.x, point.y - event.world.y) <= threshold) {
          vertex = key(point)
          break
        }
      }
      if (vertex) break
    }
    if (!vertex) return null

    const incident: Incident[] = []
    for (const shape of segments) {
      const [a, b] = endpoints(shape)
      if (key(a) === vertex) {
        incident.push({ id: shape.id, props: shape.props, anchor: b, moving: 'start' })
      } else if (key(b) === vertex) {
        incident.push({ id: shape.id, props: shape.props, anchor: a, moving: 'end' })
      }
    }
    return incident.length > 0 ? incident : null
  }

  private alreadyDrawn(from: Vec, to: Vec): boolean {
    const wanted = [key(from), key(to)].sort().join('|')
    return segmentsOf(this.editor).some((shape) => {
      const [a, b] = endpoints(shape)
      return [key(a), key(b)].sort().join('|') === wanted
    })
  }

  private geometry(from: Vec, to: Vec) {
    const box = segmentBox(from, to)
    return {
      x: box.x,
      y: box.y,
      width: box.width,
      height: box.height,
      props: {
        start: box.start,
        end: box.end,
        stroke: { color: this.settings.color, width: this.settings.width, cap: 'round' as const },
        shadow: null,
      },
    }
  }

  private snap(world: Vec): Vec {
    const { pitch } = this.settings
    return { x: Math.round(world.x / pitch) * pitch, y: Math.round(world.y / pitch) * pitch }
  }

  private abandon(): void {
    const pending = this.pending
    this.pending = null
    this.editor.clearEphemeral()
    if (pending?.kind === 'draw') {
      this.editor.transact(() => this.editor.deleteShapes([pending.draft]), { addToHistory: false })
    }
    if (this.editor.tools.state === 'dragging') this.editor.tools.setState('idle')
  }
}

/** A small plan to start from, in lattice units. */
const SEED: ReadonlyArray<readonly [number, number, number, number]> = [
  [2, 8, 10, 8],
  [10, 8, 10, 14],
  [10, 14, 2, 14],
  [2, 14, 2, 8],
  [2, 8, 6, 4],
  [6, 4, 10, 8],
  [5, 14, 5, 11],
  [5, 11, 7, 11],
  [7, 11, 7, 14],
  [3, 9, 4, 9],
  [4, 9, 4, 10],
  [4, 10, 3, 10],
  [3, 10, 3, 9],
]

export const graphPaper: Demo = ({ root, lang }) => {
  const _ = t(lang)
  const { bar, stage, setStatus, hint } = appScaffold(root, { plain: true })

  const editor = new Editor({ container: stage })
  const controls = createDefaultControls(editor)

  // Held by reference, so the sliders below reconfigure the live tool without
  // re-registering it.
  const settings: WireSettings = { pitch: 24, color: '#1d4ed8', width: 2 }

  editor.tools.register(TOOL_DRAW, (e) => new SegmentTool(e, settings, 'draw'))
  editor.tools.register(TOOL_VERTEX, (e) => new SegmentTool(e, settings, 'vertex'))
  editor.tools.register('erase', (e) => new EraseTool(e))
  editor.tools.setCurrent(TOOL_DRAW)

  /**
   * The lattice, as a single element.
   *
   * It lives inside the overlay, which already carries the viewport transform,
   * so it pans and zooms with the drawing for free — one node, one background,
   * no per-frame cost that grows with the page (invariant 3).
   */
  const grid = document.createElement('div')
  grid.className = 'hc-app-grid'
  editor.overlayElement.prepend(grid)

  const markers: HTMLElement[] = []

  radio(
    bar,
    _(['Tool', 'ツール']),
    [
      { value: TOOL_DRAW, label: _(['Draw', '線を引く']) },
      { value: TOOL_VERTEX, label: _(['Move a vertex', '頂点を動かす']) },
      { value: 'erase', label: _(['Erase', '消す']) },
    ],
    TOOL_DRAW,
    (mode) => editor.tools.setCurrent(mode),
  )

  slider(
    bar,
    _(['Pitch', '目盛り']),
    { min: 12, max: 48, step: 4, value: settings.pitch },
    (value) => {
      settings.pitch = value
    },
  )

  colorPicker(bar, _(['Ink', 'インク']), settings.color, (value) => {
    settings.color = value
  })

  button(bar, _(['Undo', '元に戻す']), () => editor.history.undo())
  button(bar, _(['Redo', 'やり直す']), () => editor.history.redo())
  button(bar, _(['Zoom to fit', '全体表示']), () => editor.viewport.zoomToFit())
  button(bar, _(['Clear', 'クリア']), () => editor.deleteShapes(editor.getChildren(null)))

  button(bar, _(['Export SVG', 'SVG を書き出し']), () => {
    if (editor.getChildren(null).length === 0) return
    const svg = editor.exportSvg({ background: '#ffffff', padding: 24 })
    download('drawing.svg', new Blob([svg], { type: 'image/svg+xml' }))
  })

  hint(
    _([
      'Draw: drag between grid points. Move a vertex: drag one of the white dots — every segment meeting it follows, and one undo puts them all back.',
      '「線を引く」は格子の点から点へドラッグします。「頂点を動かす」は白い丸をドラッグします。そこに集まっている線がすべて追従し、「元に戻す」1回でまとめて戻ります。',
    ]),
  )

  const renderGrid = (): void => {
    const zoom = editor.viewport.camera.z
    const { pitch } = settings
    if (pitch * zoom < MIN_GRID_PX) {
      grid.hidden = true
      return
    }
    const view = editor.viewport.getVisibleBounds()
    // Anchored to the lattice rather than to the viewport, so the dots sit
    // exactly where a vertex will land. The extra half-pitch is because a
    // radial-gradient tile draws its circle at the tile's centre: without it
    // every dot would sit half a square away from the point it marks.
    const half = pitch / 2
    grid.hidden = false
    grid.style.transform = `translate(${Math.floor(view.x / pitch) * pitch - half}px, ${
      Math.floor(view.y / pitch) * pitch - half
    }px)`
    grid.style.width = `${view.width + pitch * 2}px`
    grid.style.height = `${view.height + pitch * 2}px`
    grid.style.backgroundSize = `${pitch}px ${pitch}px`
  }

  const renderMarkers = (): void => {
    // Only what is on screen, which is what keeps the node count bounded no
    // matter how large the drawing gets.
    const points = new Map<string, Vec>()
    for (const id of editor.getVisibleShapeIds()) {
      const shape = editor.getResolvedShape(id)
      if (shape?.type !== 'line') continue
      for (const point of endpoints(shape)) {
        if (points.size >= MAX_MARKERS) break
        points.set(key(point), point)
      }
    }

    while (markers.length < points.size) {
      const marker = document.createElement('div')
      marker.className = 'hc-app-vertex'
      editor.overlayElement.append(marker)
      markers.push(marker)
    }

    const list = [...points.values()]
    markers.forEach((marker, index) => {
      const point = list[index]
      if (!point) {
        marker.hidden = true
        return
      }
      marker.hidden = false
      marker.style.transform = `translate(${point.x}px, ${point.y}px)`
    })
  }

  const stop = editor.onFrame(() => {
    renderGrid()
    renderMarkers()

    const segments = segmentsOf(editor)
    const vertices = new Set<string>()
    for (const shape of segments) for (const point of endpoints(shape)) vertices.add(key(point))

    setStatus(
      _([
        `${segments.length} segment(s) · ${vertices.size} vertices · ` +
          `${editor.overlayElement.querySelectorAll('*').length} overlay DOM nodes`,
        `${segments.length} 本の線 · ${vertices.size} 個の頂点 · ` +
          `オーバーレイの DOM ノード ${editor.overlayElement.querySelectorAll('*').length} 個`,
      ]),
    )
  })

  editor.transact(() => {
    const { pitch } = settings
    for (const [ax, ay, bx, by] of SEED) {
      const box = segmentBox({ x: ax * pitch, y: ay * pitch }, { x: bx * pitch, y: by * pitch })
      editor.createShape({
        type: 'line',
        x: box.x,
        y: box.y,
        width: box.width,
        height: box.height,
        props: {
          start: box.start,
          end: box.end,
          stroke: { color: settings.color, width: settings.width, cap: 'round' },
          shadow: null,
        },
      })
    }
  })

  return {
    dispose() {
      stop()
      grid.remove()
      for (const marker of markers) marker.remove()
      markers.length = 0
      controls.dispose()
      editor.dispose()
    },
  }
}

← サンプルアプリ一覧

MIT ライセンスで公開しています。