Skip to content

Commit 7495642

Browse files
authored
Merge pull request #39 from mad-p/feature/stroke_map
木を見て森を見るヒートマップ stroke_map
2 parents 8374416 + 16f7541 commit 7495642

4 files changed

Lines changed: 191 additions & 9 deletions

File tree

TODO.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
1-
# 実装規模の図示
2-
3-
プロジェクトルート以下のswiftファイルの実装規模をインフォグラフィックにしたい。
4-
5-
* ソースコードをルート直下、1段目のサブディレクトリ、テストコードに分類
6-
* コメントだけから成る行をスキップして行数を数える
7-
* ツリーマップとして図示
8-
* 各種入力モードの実装は近い色に、Mode, Keymap, Clientなどの制御系も近い色になるように
1+
# 木を見て森を見るヒートマップ
92

3+
* `basic_chars.png` では、40x40のヒートマップを作成した。これと同様に、インデックスの順を変更した `stroke_map.png` を作成する
4+
* `render_1600_heatmap` とは別に `render_1600_stroke_heatmap` を用意する
5+
* 共通に使えるコードはメソッド化する
6+
* `stroke_map.png` のヒートマップの各セルは50列32行に並ぶ
7+
* 5列4行をひとつの組として、その境界に罫線を引く。この組を「森」と呼称する
8+
* 第2打鍵が同じキーである文字は同じ森の中にある
9+
* 森の中の相対的なセルの位置が第1打鍵のキーの相対位置と一致する
10+
* 全体図を森の並びと見なして、森の位置を見ると、第2打鍵のキーの相対位置と一致する
11+
* 第1打鍵は「森の中の木」の位置、第2打鍵は「全体の中の森」の位置になる。まず木を見て、次に森を見ると、打鍵に対応するセルになっているので、「木を見て森を見るストローク表」と呼ぶ
12+
* 図全体の標題として「木を見て森を見るヒートマップ」と表示する
13+
* 0~1599のbasicCharCountのインデックスを、以下のように座標に変換する
14+
* 第1打鍵のキーコードをk1、第2打鍵をk2とすると、index == k1 * 40 + k2 である
15+
* k1 % 10が0~4であればp1 = 0、5~9であればp1 = 1とする。k2に関しても同様にp2を考える
16+
* p1, p2が0であれば左手、1であれば右手に対応する
17+
* x1 = k1 % 5, y1 = k1 / 10 (切りすて), x2 = k2 % 5, y2 = k2 / 10 とする
18+
* (x1, y1)が森の中のキーの位置、(x2, y2)が全体の中の森の位置に対応する
19+
* x = x2 * 5 + x1 + p2 * 25, y = y2 * 4 + y1 + p1 * 16 とする。
20+
* 図の左上を(x, y) = (0, 0)とし、x軸は右方向、y軸は下方向に増加する
21+
* これにより、xとyの範囲は 0 <= x <= 49、0 <= y <= 31 となる
22+
* 各セルに、そのインデックスに対応する文字を表示する
23+
* 指の名前のラベルは出力しない
24+
* RL、RR、LL、LRの象限名をヒートマップの上下に表示する
25+
* RLは左下、RRは右下、LLは左上、LRは右上に表示

scripts/lib/renderers.rb

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,164 @@ def self.render_1600_heatmap(pct_1600, out_path:, width: 1000, font_magick: nil,
248248
annotate_png(out_path, annotations, font_magick: font_magick)
249249
end
250250

251+
# ---------------------------------------------------------------------------
252+
# 木を見て森を見るストローク表ヒートマップ: 50cols x 32rows
253+
# pct_1600: 1600要素の割合配列 (basicCharCount を正規化したもの)
254+
# char_table: Hash { index(0..1599) => char } セル内文字ラベル用
255+
#
256+
# 座標変換:
257+
# k1 = index / 40, k2 = index % 40
258+
# p1 = k1%10 < 5 ? 0 : 1 (0=左手, 1=右手)
259+
# p2 = k2%10 < 5 ? 0 : 1
260+
# x1=k1%5, y1=k1/10, x2=k2%5, y2=k2/10
261+
# x = x2*5 + x1 + p2*25 (0..49)
262+
# y = y2*4 + y1 + p1*16 (0..31)
263+
# ---------------------------------------------------------------------------
264+
def self.render_1600_stroke_heatmap(pct_1600, out_path:, width: 1200, font_magick: nil,
265+
title: '木を見て森を見るヒートマップ', scale: :linear,
266+
char_table: nil)
267+
raise ArgumentError, "pct_1600 must be 1600 elements (got #{pct_1600.length})" unless pct_1600.length == 1600
268+
269+
n_cols = 50
270+
n_rows = 32
271+
272+
# 象限ラベル行(上・下それぞれ)と標題行を確保
273+
title_ps = 18
274+
title_h = title ? title_ps + 10 : 0
275+
qlabel_h = 20 # 象限ラベルの高さ
276+
pad_left = 10
277+
pad_right = 10
278+
pad_top = title_h + qlabel_h + 4
279+
pad_bottom = qlabel_h + 4
280+
281+
grid_w = width - pad_left - pad_right
282+
cell_w = (grid_w.to_f / n_cols).floor
283+
cell_h = cell_w
284+
height = pad_top + cell_h * n_rows + pad_bottom
285+
286+
img = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::WHITE)
287+
max_v = pct_1600.max.nonzero? || 1.0
288+
289+
# --- セルの塗りつぶし ---
290+
grid = Array.new(n_cols * n_rows, 0.0) # (x, y) -> pct
291+
1600.times do |idx|
292+
k1 = idx / 40
293+
k2 = idx % 40
294+
p1 = k1 % 10 < 5 ? 0 : 1
295+
p2 = k2 % 10 < 5 ? 0 : 1
296+
x1 = k1 % 5; y1 = k1 / 10
297+
x2 = k2 % 5; y2 = k2 / 10
298+
x = x2 * 5 + x1 + p2 * 25
299+
y = y2 * 4 + y1 + p1 * 16
300+
grid[y * n_cols + x] = pct_1600[idx]
301+
end
302+
303+
n_rows.times do |gy|
304+
n_cols.times do |gx|
305+
v = grid[gy * n_cols + gx]
306+
t = v / max_v.to_f
307+
col = heat_color(t, scale: scale)
308+
x0 = pad_left + gx * cell_w
309+
y0 = pad_top + gy * cell_h
310+
(0...cell_w).each do |px|
311+
(0...cell_h).each { |py| img[x0 + px, y0 + py] = col }
312+
end
313+
end
314+
end
315+
316+
# --- 罫線(森の境界) ---
317+
thin_sep = ChunkyPNG::Color.rgba(120, 120, 120, 200)
318+
thick_sep = ChunkyPNG::Color.rgba(0, 0, 0, 255)
319+
320+
# 縦線: x = 5, 10, 15, 20 | 25(太) | 30, 35, 40, 45
321+
(0...n_cols).step(5) do |gx|
322+
next if gx == 0
323+
color = (gx == 25) ? thick_sep : thin_sep
324+
x = pad_left + gx * cell_w
325+
(pad_top...(pad_top + n_rows * cell_h)).each { |yy| img[x, yy] = color if x < width }
326+
end
327+
328+
# 横線: y = 4, 8, 12 | 16(太) | 20, 24, 28
329+
(0...n_rows).step(4) do |gy|
330+
next if gy == 0
331+
color = (gy == 16) ? thick_sep : thin_sep
332+
y = pad_top + gy * cell_h
333+
(pad_left...(pad_left + n_cols * cell_w)).each { |xx| img[xx, y] = color if y < height }
334+
end
335+
336+
img.save(out_path)
337+
338+
annotations = []
339+
340+
# --- 標題 ---
341+
if title
342+
tx = (width / 2) - (title_ps * title.length * 0.3).to_i
343+
annotations << { x: [tx, 4].max, y: 4, text: title, color: 'black', pointsize: title_ps }
344+
end
345+
346+
# --- 象限ラベル ---
347+
# p1=0(左手)=上半分(y<16), p1=1(右手)=下半分(y>=16)
348+
# p2=0(左手)=左半分(x<25), p2=1(右手)=右半分(x>=25)
349+
# ラベル位置: 上(y<pad_top)と下(y>pad_top+n_rows*cell_h)
350+
ql_ps = 13
351+
left_cx = pad_left + 12 * cell_w + cell_w / 2 # 左半分中央
352+
right_cx = pad_left + 37 * cell_w + cell_w / 2 # 右半分中央
353+
top_y = title_h + 4
354+
bot_y = pad_top + n_rows * cell_h + 4
355+
356+
[
357+
{ text: 'LL', x: left_cx, y: top_y },
358+
{ text: 'LR', x: right_cx, y: top_y },
359+
{ text: 'RL', x: left_cx, y: bot_y },
360+
{ text: 'RR', x: right_cx, y: bot_y },
361+
].each do |q|
362+
tx = q[:x] - (ql_ps * q[:text].length * 0.35).to_i
363+
annotations << { x: [tx, 0].max, y: q[:y], text: q[:text], color: 'black', pointsize: ql_ps }
364+
end
365+
366+
# --- セル内文字ラベル ---
367+
if char_table
368+
char_ps = [[cell_w * 3 / 4, 6].max, 12].min
369+
1600.times do |idx|
370+
ch = char_table[idx]
371+
next unless ch
372+
373+
k1 = idx / 40
374+
k2 = idx % 40
375+
p1 = k1 % 10 < 5 ? 0 : 1
376+
p2 = k2 % 10 < 5 ? 0 : 1
377+
x1 = k1 % 5; y1 = k1 / 10
378+
x2 = k2 % 5; y2 = k2 / 10
379+
gx = x2 * 5 + x1 + p2 * 25
380+
gy = y2 * 4 + y1 + p1 * 16
381+
382+
v = grid[gy * n_cols + gx]
383+
t = v / max_v.to_f
384+
t = (scale == :log && t > 0) ? Math.log(t * (Math::E - 1) + 1) : t
385+
text_color = t > 0.6 ? 'white' : 'black'
386+
387+
x0 = pad_left + gx * cell_w
388+
y0 = pad_top + gy * cell_h
389+
tx = x0 + (cell_w / 2) - (char_ps * 0.5).to_i
390+
ty = y0 + (cell_h / 2) - (char_ps * 0.5).to_i
391+
annotations << { x: [tx, pad_left].max, y: [ty, pad_top].max,
392+
text: ch, color: text_color, pointsize: char_ps }
393+
end
394+
end
395+
396+
annotate_png(out_path, annotations, font_magick: font_magick)
397+
end
398+
399+
# stroke_map wrapper
400+
def self.render_stroke_map(pct_1600, out_path:, width: 1200, font_magick: nil,
401+
title: '木を見て森を見るヒートマップ', scale: :linear)
402+
require_relative 'tcode'
403+
char_table = Tcode.all_chars
404+
render_1600_stroke_heatmap(pct_1600, out_path: out_path, width: width,
405+
font_magick: font_magick, title: title,
406+
scale: scale, char_table: char_table)
407+
end
408+
251409
# Bigram heatmap (wrapper)
252410
def self.render_bigram(bigram_pct, out_path:, width: 1000, font_magick: nil, title: nil, scale: :linear)
253411
render_1600_heatmap(bigram_pct, out_path: out_path, width: width,

scripts/plot_strokes.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,14 @@
216216
Renderers.render_basic_chars(basic_char_percent, out_path: basic_chars_path, width: options[:width],
217217
font_magick: options[:font_magick], title: '基本文字ヒートマップ',
218218
scale: options[:scale])
219+
220+
# 木を見て森を見るストローク表
221+
stroke_map_path = File.join(out_dir, 'stroke_map.png')
222+
puts "Rendering stroke_map -> #{stroke_map_path}"
223+
Renderers.render_stroke_map(basic_char_percent, out_path: stroke_map_path, width: options[:width],
224+
font_magick: options[:font_magick],
225+
title: '木を見て森を見るヒートマップ',
226+
scale: options[:scale])
219227
else
220228
warn 'basicCharCount total is zero; skipping basic_chars chart'
221229
end

scripts/tests/run_tests.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def assert(desc, result)
148148

149149
assert('script exits with code 0', exit_code == 0)
150150

151-
%w[heatmap.png fingers.png rows.png panes.png alternation.png bigram.png basic_chars.png percentile.png].each do |fname|
151+
%w[heatmap.png fingers.png rows.png panes.png alternation.png bigram.png basic_chars.png stroke_map.png percentile.png].each do |fname|
152152
path = File.join(out_dir, fname)
153153
assert("#{fname} was generated", File.exist?(path) && File.size(path) > 1000)
154154
end

0 commit comments

Comments
 (0)