@@ -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 ,
0 commit comments