kakitori

A handwriting practice library for kanji, kana, and numbers. Per-stroke tome / hane / harai check, composable practice problems, and a paper-style multi-block layout.

npm install @k1low/kakitori

kakitori is built around three primitives: char for one character, block for one practice problem (cells + furigana), and page for a full vertical-rl practice sheet of many blocks.

char

char handles a single character. You can render it as a static SVG, mount an interactive writer that checks each stroke, or check strokes headlessly without a DOM.

import { char } from "@k1low/kakitori";

// Mount an interactive writer that checks each stroke.
const target = document.getElementById("writer")!;
const c = char.create("学");
c.mount(target, {
  size: 300,
  onCorrectStroke: (data) => console.log("OK", data.strokeNum),
  onMistake: (data) => console.log("NG", data.strokeNum),
  onComplete: ({ totalMistakes }) => console.log("done", totalMistakes),
});
c.start();

// Static SVG render with no interaction.
char.render(document.getElementById("preview")!, "学", { size: 80 });

Live example. Pick any character from the gallery below to start a practice session. The slot row shows the expected stroke ending (tome / hane / harai) for each stroke, and is updated to OK / NG after each stroke is checked.


        

Examples

Five common mount configurations. Each example has its own writer instance — Restart re-arms the writer from scratch (clears the canvas + starts a fresh practice session). The chip row below each cell colors green / red as strokes are checked.

1. Normal

Default mount: cross-grid on, hanzi-writer paints each accepted stroke.

const c = char.create("学");
c.mount(target, { size: 160 });
c.start();

2. No grid

Same configuration as above, but the cross-grid is suppressed for a cleaner cell background.

const c = char.create("学");
c.mount(target, { size: 160, showGrid: false });
c.start();

3. No outline

The faint outline is suppressed too, so the cell starts completely blank.

const c = char.create("学");
c.mount(target, { size: 160, showOutline: false });
c.start();

4. Per-char correction

User writes every stroke freely without per-stroke rejection. Correction runs once all strokes are captured; NG attempts wipe and re-arm in place.

const c = char.create("学");
c.mount(target, { size: 160, correction: "per-char" });
c.start();

5. Retain strokes, hide accepted stroke

Paper-like UX — the user's own ink stays on screen after each accepted stroke, and hanzi-writer's official gray stroke is suppressed.

const c = char.create("学");
c.mount(target, {
  size: 160,
  retainStrokes: true,
  showAcceptedStroke: false,
});
c.start();

CharResult

c.result() returns a CharResult. This is the leaf type used across the whole stack. The same shape comes back from headless Char.checkStroke() and from the mounted interactive path.

interface CharResult {
  character: string;
  complete: boolean;   // every logical stroke has been observed
  matched: boolean;    // every observed stroke matched
  perStroke: CharStrokeResult[];
  mistakes?: number;             // guided-only
  strokeEndingMistakes?: number; // guided-only
  similarity?: number;           // free / annotation only
  candidate?: string;            // free / annotation only
  // The two fields below are populated when a CharResult comes through
  // a Block / Page result tree (undefined for standalone char.result()).
  source?: "guided" | "free" | "annotation";
  mode?: "write" | "show";
}

interface CharStrokeResult {
  matched: boolean;
  similarity: number;
  strokeEnding?: StrokeEndingResult;
  points?: TimedPoint[];      // raw drawn samples with timestamps
  mistakesOnStroke?: number;  // guided write only
  isBackwards?: boolean;      // guided write only
}

block

block represents one practice problem: a row of cells plus an optional furigana annotation strip. Cells can be guided (one specific character to write or show), free (any of an expected string, matched freehand), or blank (visual placeholder).

import { block } from "@k1low/kakitori/block";

const target = document.getElementById("block-host")!;
const b = block.create(target, {
  spec: {
    cells: [
      { kind: "guided", char: "学", mode: "write" },
      { kind: "guided", char: "校", mode: "write" },
    ],
    annotations: [
      { cellRange: [0, 1], expected: "がっこう", mode: "write" },
    ],
  },
  cellSize: 140,
  onCellComplete: (index, kind, chars) => { /* per cell finished */ },
  onBlockComplete: (result) => { /* this problem finished */ },
});

// Get the structured result tree at any time.
const result = b.result();

Live example. The selector switches between use cases that mix guided / free cells with and without furigana.


      

Examples

Five common block configurations. Each example has its own block instance — Restart re-creates the block from scratch. The chip row below each block colors green / red per cell as onCellComplete fires.

1. Normal (two guided cells, write)

Two guided cells side by side. The user writes both characters in order; each cell commits independently.

const b = block.create(target, {
  spec: {
    cells: [
      { kind: "guided", char: "学", mode: "write" },
      { kind: "guided", char: "校", mode: "write" },
    ],
  },
  cellSize: 140,
});

2. Show + write

First cell renders the template (read-only); second cell is the writable target. Useful for "trace then write" prompts.

const b = block.create(target, {
  spec: {
    cells: [
      { kind: "guided", char: "学", mode: "show" },
      { kind: "guided", char: "校", mode: "write" },
    ],
  },
  cellSize: 140,
});

3. Free cell

A free cell decouples visible width from answer length: expected: "学校" (2 characters) but span: 3 reserves 3 grid slots, so the user can write freely across the strip.

const b = block.create(target, {
  spec: {
    cells: [
      { kind: "free", expected: "学校", mode: "write", span: 3 },
    ],
  },
  cellSize: 140,
});

4. With furigana annotation

Two guided cells plus a write-mode furigana strip spanning both. The annotation accepts the reading freely across the strip.

const b = block.create(target, {
  spec: {
    cells: [
      { kind: "guided", char: "学", mode: "write" },
      { kind: "guided", char: "校", mode: "write" },
    ],
    annotations: [
      { cellRange: [0, 1], expected: "がっこう", mode: "write" },
    ],
  },
  cellSize: 140,
});

5. Per-block correction

Block-wide correction: "per-block": every cell defers its check until the entire block is written. NG cells wipe and re-arm in place; onCellComplete / onBlockComplete fire in one burst on the final OK round.

const b = block.create(target, {
  spec: {
    cells: [
      { kind: "guided", char: "学", mode: "write" },
      { kind: "guided", char: "校", mode: "write" },
    ],
  },
  cellSize: 140,
  correction: "per-block",
});

BlockResult

b.result() returns a BlockResult. Each cell exposes its CharResult[] (one entry per expected character); each annotation does the same. mode: "show" entries are display-only and always come back complete: true with an empty perStroke.

interface BlockResult {
  id?: string;        // echoed from PageBlockEntry.id when generated by Page
  complete: boolean;  // all writable chars completed
  matched: boolean;   // no observed failures
  cells: BlockCellResult[];
  annotations: BlockAnnotationResult[];
}

interface BlockCellResult {
  kind: "guided" | "free" | "blank";
  chars: CharResult[]; // guided=1, free=expected length, blank=0
}

interface BlockAnnotationResult {
  chars: CharResult[]; // one entry per expected character
}

page

page lays out multiple blocks on a vertical-rl grid (Japanese practice-sheet style). Blocks flow column-by-column. A block that crosses a column boundary is split per-cell automatically, even when it carries a furigana annotation, and strokes drawn across split surfaces are checked from a shared buffer.

import { page } from "@k1low/kakitori/page";

const target = document.getElementById("page-host")!;
const p = page.create(target, {
  writingMode: "vertical-rl",
  columns: 5,
  cellsPerColumn: 8,
  cellSize: 96,
  blocks: [
    {
      id: "q1",
      spec: {
        cells: [
          { kind: "guided", char: "学", mode: "write" },
          { kind: "guided", char: "校", mode: "write" },
        ],
        annotations: [
          { cellRange: [0, 1], expected: "がっこう", mode: "show" },
        ],
      },
    },
    { id: "q2", spec: { cells: [{ kind: "guided", char: "山", mode: "write" }] } },
    // ...
  ],
  onBlockComplete: (blockIndex, result) => { /* one problem done */ },
  onPageComplete: (result) => { /* whole sheet done */ },
});

Live example. Notice block q9 (図書館 + としょかん) crossing a column boundary, and block q10 rendering as a 5-cell visual blank.


      

PageResult

p.result() returns a PageResult. Each block carries a BlockResult directly, with the original id echoed back so a caller can correlate results against the input blocks.

interface PageResult {
  complete: boolean;
  matched: boolean;
  blocks: BlockResult[]; // each entry mirrors the order of opts.blocks
}

// Flatten + filter all CharResult leaves across a result tree.
import { collectCharResults } from "@k1low/kakitori";

const scored = collectCharResults(p.result(), {
  sources: ["guided"],
  modes: ["write"],
  completedOnly: true,
});