180 days until RubyKaigi 2027 (2026-10-16 JST)

Share on X Share on Bluesky

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)

The Ruby script that generated this page (317 lines)
#! /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&#39;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 伊藤製作所