180 days until RubyKaigi 2027 (2026-10-16 JST)
This page and its og:image are generated by a Ruby script just after midnight every day (no gems).
generated: 10/16 00:01 (JST) Ruby 4.0.6 (x86_64-linux)
#! /usr/bin/env ruby
# RubyKaigi Counter: shows the number of days until RubyKaigi 2027 on a 16x16 LED matrix.
# This single file generates the HTML, the SVG and the PNG (og:image).
# No gems are used (only zlib / erb / uri from the standard library).
# Runs by cron at 0:01 (JST) every day and puts these into /var/www/notes/misc/ruby-kaigi-counter/.
# index.html The page of today.
# YYYYMMDD.html The page of the day (the URL to share on SNS. It keeps showing the number of that day.)
# YYYYMMDD.png The og:image of the day (1200x630).
#
# How to test:
# ruby gen-ruby-kaigi-counter.rb /tmp/ruby-kaigi-counter [YYYY-MM-DD]
require 'zlib'
require 'erb'
require 'uri'
require 'date'
require 'fileutils'
VERSION = "2026.10.13(01)"
BASE_URL = "https://lmlab.net/misc/ruby-kaigi-counter"
DST_DIR = "/var/www/notes/misc/ruby-kaigi-counter"
DAY1 = Date.new(2027, 4, 14)
# ════════════════════════════════════════════════════
# LED class
# position: top-left=0, bottom-right=255 (16x16, row-major)
# ════════════════════════════════════════════════════
class LED
COLS = 16
ROWS = 16
OFF = [0, 0, 0]
# 3x5 pixel font (rows x columns of 0/1)
FONT = {
'0' => [[1,1,1],[1,0,1],[1,0,1],[1,0,1],[1,1,1]],
'1' => [[0,1,0],[0,1,0],[0,1,0],[0,1,0],[0,1,0]],
'2' => [[1,1,1],[0,0,1],[1,1,1],[1,0,0],[1,1,1]],
'3' => [[1,1,1],[0,0,1],[1,1,1],[0,0,1],[1,1,1]],
'4' => [[1,0,1],[1,0,1],[1,1,1],[0,0,1],[0,0,1]],
'5' => [[1,1,1],[1,0,0],[1,1,1],[0,0,1],[1,1,1]],
'6' => [[1,1,1],[1,0,0],[1,1,1],[1,0,1],[1,1,1]],
'7' => [[1,1,1],[0,0,1],[0,0,1],[0,0,1],[0,0,1]],
'8' => [[1,1,1],[1,0,1],[1,1,1],[1,0,1],[1,1,1]],
'9' => [[1,1,1],[1,0,1],[1,1,1],[0,0,1],[1,1,1]],
'-' => [[0,0,0],[0,0,0],[1,1,1],[0,0,0],[0,0,0]],
' ' => [[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]],
'D' => [[1,1,0],[1,0,1],[1,0,1],[1,0,1],[1,1,0]],
'A' => [[0,1,0],[1,0,1],[1,1,1],[1,0,1],[1,0,1]],
'Y' => [[1,0,1],[1,0,1],[0,1,0],[0,1,0],[0,1,0]],
}
attr_reader :pixels
def initialize
@pixels = Array.new(COLS * ROWS) { OFF }
end
# Set the color of an LED.
def set_rgb(position, red, green, blue)
return unless (0...COLS * ROWS).cover?(position)
@pixels[position] = [red, green, blue]
end
# Draw a string (3 columns + 1 column gap = 4 columns per character).
# position: the LED number of the top-left corner
def set_text(position, str, color)
base_row = position / COLS
base_col = position % COLS
str.chars.each_with_index do |ch, ci|
(FONT[ch] || FONT[' ']).each_with_index do |row_bits, ri|
row_bits.each_with_index do |bit, bi|
row = base_row + ri
col = base_col + ci * 4 + bi
next if row >= ROWS || col >= COLS
set_rgb(row * COLS + col, *(bit == 1 ? color : OFF))
end
end
end
end
# Draw a right-aligned 4-digit number (-999..9999).
def set_num(position, number, color)
n = number.to_i.clamp(-999, 9999)
set_text(position, n < 0 ? "-#{n.abs.to_s.rjust(3)}" : n.to_s.rjust(4), color)
end
# Yield the coordinates (from the top-left of the matrix) and the color of each LED.
def each_led(cell, size)
pad = (cell - size) / 2
@pixels.each_with_index do |rgb, pos|
yield (pos % COLS) * cell + pad, (pos / COLS) * cell + pad, rgb
end
end
end
# ════════════════════════════════════════════════════
# PNG encoder (24bit RGB)
# ════════════════════════════════════════════════════
class Canvas
def initialize(width, height, bg)
@width = width
@height = height
@rows = Array.new(height) { bg.pack('C3') * width }
end
def fill_rect(x, y, w, h, color)
line = color.pack('C3') * w
(y...y + h).each { |yy| @rows[yy][x * 3, w * 3] = line }
end
# Rounded rectangle (no anti-aliasing on the corners).
def fill_rrect(x, y, w, h, r, color)
px = color.pack('C3')
h.times do |dy|
cy = if dy < r then r - dy - 0.5
elsif dy >= h - r then dy - (h - r) + 0.5
end
inset = cy ? (r - Math.sqrt(r * r - cy * cy)).ceil : 0
@rows[y + dy][(x + inset) * 3, (w - inset * 2) * 3] = px * (w - inset * 2)
end
end
def to_png
raw = @rows.map { |row| "\x00".b + row }.join # Each row starts with a filter type (0=None).
"\x89PNG\r\n\x1a\n".b +
chunk('IHDR', [@width, @height, 8, 2, 0, 0, 0].pack('NNC5')) +
chunk('IDAT', Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION)) +
chunk('IEND', '')
end
private
def chunk(type, data)
[data.bytesize].pack('N') + type + data + [Zlib.crc32(type + data)].pack('N')
end
end
# ════════════════════════════════════════════════════
# Drawing
# ════════════════════════════════════════════════════
RED = [230, 50, 25]
CYAN = [0, 200, 255]
AMBER = [255, 190, 0]
OFF_COLOR = [0x1a, 0x1a, 0x1a]
BOARD = [0x2d, 0x2d, 0x2d]
EDGE = [0x48, 0x48, 0x48]
CELL = 34
LED_SIZE = 28
MARGIN = 18
BOARD_SIZE = LED::COLS * CELL + MARGIN * 2
# The day number counted from the first day (4/12=-1, 4/13=0, 4/14=1)
def counter(today)
1 - (DAY1 - today).to_i
end
def build_led(n)
led = LED.new
# Show "27" on the right (call it first, then overwrite with the pattern).
led.set_num(49, 27, RED)
# Ruby logo
[51,52,53,66,67,68,69,70,83,84,85,100].each { |pos| led.set_rgb(pos, *RED) }
if (1..9).cover?(n)
# A single digit looks lonely, so show "DAY 1" (no room for a space, so change the color instead).
led.set_text(161, "DAY", CYAN)
led.set_text(161 + 12, n.to_s, AMBER)
else
led.set_num(161, n, CYAN)
end
led
end
def color(rgb)
"#%02x%02x%02x" % (rgb == LED::OFF ? OFF_COLOR : rgb)
end
def to_svg(led)
rects = []
led.each_led(CELL, LED_SIZE) do |x, y, rgb|
rects << %(<rect x="#{x + MARGIN}" y="#{y + MARGIN}" width="#{LED_SIZE}" height="#{LED_SIZE}" fill="#{color(rgb)}"/>)
end
<<~SVG
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 #{BOARD_SIZE + 2} #{BOARD_SIZE + 2}" role="img">
<rect width="#{BOARD_SIZE}" height="#{BOARD_SIZE}" rx="16" fill="#{color(BOARD)}" stroke="#{color(EDGE)}" stroke-width="1.5"/>
#{rects.join("\n ")}
</svg>
SVG
end
def to_png(led)
w = 1200
h = 630
bx = (w - BOARD_SIZE) / 2
by = (h - BOARD_SIZE) / 2
cv = Canvas.new(w, h, [0, 0, 0])
cv.fill_rrect(bx - 2, by - 2, BOARD_SIZE + 4, BOARD_SIZE + 4, 18, EDGE)
cv.fill_rrect(bx, by, BOARD_SIZE, BOARD_SIZE, 16, BOARD)
led.each_led(CELL, LED_SIZE) do |x, y, rgb|
cv.fill_rect(bx + MARGIN + x, by + MARGIN + y, LED_SIZE, LED_SIZE, rgb == LED::OFF ? OFF_COLOR : rgb)
end
cv.to_png
end
def message(n)
return "RubyKaigi 2027 DAY #{n}" if n >= 1
days = 1 - n
"#{days} #{days == 1 ? "day" : "days"} until RubyKaigi 2027"
end
def write(path, data)
File.binwrite("#{path}.tmp", data)
File.rename("#{path}.tmp", path)
end
if __FILE__ == $0
dst = ARGV[0] || DST_DIR
now = Time.now.getlocal("+09:00")
today = ARGV[1] ? Date.parse(ARGV[1]) : now.to_date
ymd = today.strftime("%Y%m%d")
n = counter(today)
led = build_led(n)
include ERB::Util
text = message(n)
permalink = "#{BASE_URL}/#{ymd}.html"
share = "#RubyKaigi 2027 Day #{n}"
vars = {
today: today, now: now, text: text, permalink: permalink,
svg: to_svg(led),
alt: "LED matrix showing: #{text}",
image: "#{BASE_URL}/#{ymd}.png",
share_x: "https://x.com/intent/post?" + URI.encode_www_form(text: share, url: permalink),
share_bs: "https://bsky.app/intent/compose?" + URI.encode_www_form(text: "#{share} #{permalink}"),
source: File.read(__FILE__, encoding: "UTF-8"),
}
DATA.set_encoding("UTF-8") # The locale is C under cron.
template = ERB.new(DATA.read, trim_mode: "-")
FileUtils.mkdir_p(dst)
write("#{dst}/#{ymd}.png", to_png(led))
{ "#{ymd}.html" => false, "index.html" => true }.each do |name, latest|
write("#{dst}/#{name}", template.result_with_hash(vars.merge(latest: latest)))
end
puts "Generated: #{dst}/{index.html,#{ymd}.html,#{ymd}.png} (#{text})"
end
__END__
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>RubyKaigi Counter</title>
<meta name="description" content="<%=h text %>"/>
<link rel="canonical" href="<%=h latest ? "#{BASE_URL}/" : permalink %>"/>
<meta property="og:type" content="website"/>
<meta property="og:site_name" content="伊藤製作所"/>
<meta property="og:title" content="RubyKaigi Counter"/>
<meta property="og:description" content="<%=h text %>"/>
<meta property="og:url" content="<%=h permalink %>"/>
<meta property="og:image" content="<%=h image %>"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:image:alt" content="<%=h alt %>"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:site" content="@yoseiito"/>
<style>
body {
margin: 0;
background: #000;
color: #777;
font-family: monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 28px 16px;
box-sizing: border-box;
}
a { color: #999; text-decoration: none; }
a:hover { color: #4a90d9; }
.board { display: block; width: min(420px, 100%); }
.board svg { display: block; width: 100%; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.85); border-radius: 16px; }
p { margin: 12px 0 0; font-size: 12px; line-height: 1.6; text-align: center; }
.share a { display: inline-block; margin: 4px 6px 0; padding: 6px 14px; border: 1px solid #484848; border-radius: 16px; }
.share a:hover { border-color: #4a90d9; }
details { margin-top: 20px; width: min(860px, 100%); font-size: 12px; }
summary { cursor: pointer; text-align: center; }
pre { overflow-x: auto; padding: 12px; background: #111; color: #9a9a9a; border-radius: 8px; line-height: 1.5; }
#credit { font-size: 9px; color: #555; }
</style>
</head>
<body>
<a class="board" href="https://rubykaigi.org/" target="_blank" rel="noopener" aria-label="<%=h alt %>">
<%= svg.gsub("\n", "\n ").strip %>
</a>
<p><%=h text %> <span>(<%=h today.strftime("%Y-%m-%d") %> JST)</span></p>
<p class="share">
<a href="<%=h share_x %>" target="_blank" rel="noopener">Share on X</a>
<a href="<%=h share_bs %>" target="_blank" rel="noopener">Share on Bluesky</a>
</p>
<%- unless latest -%>
<p><a href="./">→ See today's counter</a></p>
<%- end -%>
<p>
This page and its og:image are generated by a Ruby script just after midnight every day (no gems).<br>
generated: <%=h now.strftime("%m/%d %H:%M") %> (JST) Ruby <%=h RUBY_VERSION %> (<%=h RUBY_PLATFORM %>)
</p>
<details>
<summary>The Ruby script that generated this page (<%= source.lines.size %> lines)</summary>
<pre><code><%=h source %></code></pre>
</details>
<p><a href="/sandbox/ruby-kaigi-counter.html">→ ruby.wasm version (runs in your browser)</a></p>
<p id="credit"><%=h VERSION %> CC BY <a href="https://lmlab.net/posts/2026/2026-04-28-road-to-ruby-kaigi-miyazaki.html">伊藤製作所</a></p>
</body>
</html>
→ ruby.wasm version (runs in your browser)
2026.10.13(01) CC BY 伊藤製作所