sujiko 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: efc1d04c17508c044c19cfa9ee51f71e00ca0635b8ea2c586aee4f603fde90a1
4
+ data.tar.gz: 51aaee86eab07a1afe5854b9e5839db56e58b1c3b2620473b78978a80193d86a
5
+ SHA512:
6
+ metadata.gz: c5274ff9cd50ec4f25400acfbf68b254f79098a8dfbc6fc4ce8c99e0da983fd49d2ff7d1a1df59a970136af1415c4251f197bc583090456905e7dba6fcf1efca
7
+ data.tar.gz: 933de2d231eadfee62a2523ebc126cab5df6dca857bc0bf387649de4ff736e8e830dd70d5b5003e256d5a7f0b24a2132f36030429a98ebd1c4f5410eed7c1ae9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 TODO: Write your name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Sujiko
2
+
3
+ **当然ジョーク用(お遊び)の gem です**
4
+ **This is a joke / toy gem — for fun and local experiments, not for production use.**
5
+
6
+ A local development server for a **venue meetup map**: one `GET /` page with optional query parameters `shape`, `x`, and `y` (same rules as a Rails `SpotsController`-style app and the iOS URL builder). Normalized coordinates `0.0`–`1.0` with the top-left of the white floor as origin; `shape` picks the room (e.g. `roomA` → `room_a` after normalization). The server listens on `127.0.0.1` and, on macOS, opens your default browser on startup.
7
+
8
+ Example: `http://127.0.0.1:4567/?shape=roomA&x=0.3&y=0.4`
9
+
10
+ ## Installation
11
+
12
+ With Bundler, add to your `Gemfile`:
13
+
14
+ ```bash
15
+ bundle add sujiko
16
+ ```
17
+
18
+ Without Bundler:
19
+
20
+ ```bash
21
+ gem install sujiko
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### CLI
27
+
28
+ ```bash
29
+ sujiko # default port 4567
30
+ sujiko 3000 # custom port
31
+ ```
32
+
33
+ `--public-origin` / `-o` sets the **origin** (scheme, host, optional non-default port only—no path, query, or userinfo) for **copied** “meetup” URLs. If omitted, the page uses the browser’s current `location.origin`, same as before.
34
+
35
+ From a cloned repository:
36
+
37
+ ```bash
38
+ bundle exec sujiko
39
+ ```
40
+
41
+ | Environment | Effect |
42
+ |-------------|--------|
43
+ | `SUJIKO_NO_BROWSER` | If set, the server does not open a browser on startup. |
44
+
45
+ ### `GET /` query parameters
46
+
47
+ | Name | Meaning |
48
+ |------|---------|
49
+ | `shape` | Venue id (e.g. `roomA`); normalized server-side to internal ids like `room_a` / `room_b` / `room_c` / `room_main`. |
50
+ | `x`, `y` | Normalized position on the white floor, each `0.0`–`1.0` (clamped; parse failures fall back to `0.5`). |
51
+
52
+ The page reads these from the URL on load; use **Copy** in the UI to get a `?shape&x&y` link you can open in Safari or share. The copied URL’s **origin** follows `--public-origin` when the server was started with it; otherwise it matches the page you are viewing.
53
+
54
+ ### Programmatically
55
+
56
+ ```ruby
57
+ require "sujiko"
58
+
59
+ Sujiko::Server.start
60
+ Sujiko::Server.start(port: 8080)
61
+ ```
62
+
63
+ Press `Ctrl-C` to stop the server.
64
+
65
+ ## Development
66
+
67
+ After checkout, run `bin/setup` to install dependencies. Use `bin/console` for an interactive session.
68
+
69
+ `bundle exec rake install` installs the gem locally. To release, bump the version in `lib/sujiko/version.rb` and run `bundle exec rake release` (creates a git tag, pushes commits, uploads the `.gem` to [rubygems.org](https://rubygems.org)).
70
+
71
+ ## Contributing
72
+
73
+ Bug reports and pull requests are welcome on GitHub: https://github.com/tutuitakumi/sujiko
74
+
75
+ ## License
76
+
77
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/exe/sujiko ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "sujiko"
6
+
7
+ options = { port: nil, public_origin: nil }
8
+ parser = OptionParser.new do |opts|
9
+ opts.banner = "Usage: sujiko [options] [PORT]\n\n"
10
+ opts.on(
11
+ "-o", "--public-origin URL",
12
+ "Origin for copied URLs (scheme://host[:port]; default: current page's location.origin)"
13
+ ) { |v| options[:public_origin] = v }
14
+ opts.on("-h", "--help", "Show this help") do
15
+ puts opts
16
+ exit 0
17
+ end
18
+ end
19
+ parser.parse!
20
+ options[:port] = ARGV[0] if ARGV[0]
21
+
22
+ Sujiko::Server.start(port: options[:port], public_origin: options[:public_origin])
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "rbconfig"
6
+ require "socket"
7
+ require "uri"
8
+
9
+ module Sujiko
10
+ class Server
11
+ DEFAULT_PORT = 4567
12
+ MAX_REQUEST_HEADER_SIZE = 16_384
13
+ TEMPLATE = File.expand_path("templates/index.html.erb", __dir__)
14
+
15
+ def self.start(port: DEFAULT_PORT, public_origin: nil, out: $stdout, err: $stderr)
16
+ port = DEFAULT_PORT if port.nil?
17
+ new(port, out, err, public_origin).start
18
+ end
19
+
20
+ def start
21
+ port = resolve_port
22
+ html = render_template(port:)
23
+ base_url = "http://127.0.0.1:#{port}"
24
+ body_utf8 = html.encode(Encoding::UTF_8)
25
+
26
+ server = nil
27
+ server = TCPServer.new("127.0.0.1", port)
28
+ trap("INT") { server&.close }
29
+ @out.puts "Sujiko: #{base_url} (Ctrl-C で停止)"
30
+ try_open_browser(base_url, @err)
31
+
32
+ loop do
33
+ socket = server.accept
34
+ begin
35
+ handle_socket(socket, body_utf8)
36
+ ensure
37
+ socket.close
38
+ end
39
+ end
40
+ rescue Errno::EBADF, Errno::EINVAL, IOError
41
+ # 終了: `accept` 中にソケットを閉じたあと
42
+ ensure
43
+ server&.close
44
+ end
45
+
46
+ private
47
+
48
+ def initialize(port, out, err, public_origin = nil)
49
+ @port_argument = port
50
+ @out = out
51
+ @err = err
52
+ @public_origin = validate_public_origin(public_origin)
53
+ end
54
+
55
+ def render_template(port:)
56
+ body = File.read(TEMPLATE, encoding: "UTF-8")
57
+ title = "Sujiko"
58
+ # text/ruby 内で `COPY_PUBLIC_ORIGIN = <%= copy_public_origin_ruby %>` として埋め込む(JSON の文字列リテラル = Ruby と互換のエスケープ)
59
+ copy_public_origin_ruby = @public_origin.nil? ? "nil" : JSON.generate(@public_origin)
60
+ ERB.new(body).result(binding)
61
+ end
62
+
63
+ def validate_public_origin(raw)
64
+ return nil if raw.nil?
65
+
66
+ s = raw.to_s.strip
67
+ return nil if s.empty?
68
+
69
+ u = URI.parse(s)
70
+ unless %w[http https].include?(u.scheme)
71
+ @err.puts "Sujiko: public_origin は http または https である必要があります。無視します: #{raw.inspect}"
72
+ return nil
73
+ end
74
+ unless u.host
75
+ @err.puts "Sujiko: public_origin にホストがありません。無視します: #{raw.inspect}"
76
+ return nil
77
+ end
78
+ if u.user || u.password
79
+ @err.puts "Sujiko: public_origin にユーザー情報は付けないでください。無視します: #{raw.inspect}"
80
+ return nil
81
+ end
82
+ if u.query || u.fragment
83
+ @err.puts "Sujiko: public_origin にクエリ・フラグメントは付けないでください。無視します: #{raw.inspect}"
84
+ return nil
85
+ end
86
+
87
+ pth = u.path
88
+ if pth && pth != "" && pth != "/"
89
+ @err.puts "Sujiko: public_origin にパスは付けないでください。無視します: #{raw.inspect}"
90
+ return nil
91
+ end
92
+
93
+ out = +"#{u.scheme}://#{u.host}"
94
+ out << ":#{u.port}" if u.port && !((u.scheme == "http" && u.port == 80) || (u.scheme == "https" && u.port == 443))
95
+ out
96
+ rescue URI::InvalidURIError
97
+ @err.puts "Sujiko: public_origin を解釈できません。無視します: #{raw.inspect}"
98
+ nil
99
+ end
100
+
101
+ def resolve_port
102
+ p = @port_argument
103
+ return DEFAULT_PORT if p.nil?
104
+
105
+ s = p.to_s
106
+ return DEFAULT_PORT if s.empty?
107
+
108
+ n = Integer(s, 10)
109
+ n.positive? ? n : DEFAULT_PORT
110
+ rescue ArgumentError, TypeError
111
+ DEFAULT_PORT
112
+ end
113
+
114
+ def try_open_browser(url, err)
115
+ return if ENV["SUJIKO_NO_BROWSER"]
116
+
117
+ if RbConfig::CONFIG["host_os"] =~ /darwin|mac os/
118
+ system("open", url)
119
+ elsif RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin|bccwin|wince/
120
+ system("start", "", url)
121
+ else
122
+ system("xdg-open", url) # XDG on Linux/Unix
123
+ end
124
+ rescue StandardError => e
125
+ err.puts "ブラウザを開けませんでした: #{e.message}"
126
+ end
127
+
128
+ def handle_socket(socket, body_utf8)
129
+ first_line = read_request_line(socket)
130
+ if first_line.nil? || first_line !~ %r{^[A-Z]+\s}
131
+ write_text_response(socket, 400, "Bad Request", "")
132
+ return
133
+ end
134
+
135
+ method = first_line.split(/\s+/, 2).first
136
+ case method
137
+ when "GET"
138
+ write_html_response(socket, body_utf8, head_only: false)
139
+ when "HEAD"
140
+ write_html_response(socket, body_utf8, head_only: true)
141
+ else
142
+ write_text_response(socket, 405, "Method Not Allowed", "")
143
+ end
144
+ end
145
+
146
+ def read_request_line(socket)
147
+ buf = +""
148
+ until request_headers_complete?(buf)
149
+ if buf.bytesize > MAX_REQUEST_HEADER_SIZE
150
+ return
151
+ end
152
+
153
+ data = socket.readpartial(8_192)
154
+ return if data.nil? || data.empty?
155
+
156
+ buf << data
157
+ end
158
+ buf.split("\r\n", 2).first
159
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError
160
+ nil
161
+ end
162
+
163
+ def request_headers_complete?(buf)
164
+ buf.end_with?("\r\n\r\n") || buf.match?(%r{\r\n\r\n|\n\n})
165
+ end
166
+
167
+ def write_html_response(socket, body_utf8, head_only:)
168
+ payload = head_only ? +"" : body_utf8
169
+ header = "HTTP/1.1 200 OK\r\n" \
170
+ "Content-Type: text/html; charset=utf-8\r\n" \
171
+ "Content-Length: #{body_utf8.bytesize}\r\n" \
172
+ "Connection: close\r\n" \
173
+ "\r\n"
174
+ socket.write(header.b)
175
+ socket.write(payload.b) unless payload.empty?
176
+ end
177
+
178
+ def write_text_response(socket, status, message, _body = "")
179
+ text = "HTTP/1.1 #{status} #{message}\r\n" \
180
+ "Content-Length: 0\r\n" \
181
+ "Connection: close\r\n" \
182
+ "\r\n"
183
+ socket.write(text.b)
184
+ end
185
+
186
+ private_class_method :new
187
+ end
188
+ end
@@ -0,0 +1,483 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title><%= title %></title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Space+Mono:wght@400;700&family=Syne:wght@700;800&display=swap" rel="stylesheet" />
10
+ <style>
11
+ * { box-sizing: border-box; }
12
+ body { font-family: system-ui, sans-serif; margin: 2rem; color: #111; }
13
+ .server-meta { color: #666; font-size: 0.9rem; margin-top: 0.5rem; }
14
+ .title-bar {
15
+ max-width: 24rem; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem;
16
+ border: 1px solid #e0e0e0; border-radius: 6px; background: #fafafa;
17
+ font-size: 1.1rem; font-weight: 600;
18
+ }
19
+ .venue-block { max-width: min(100vw - 2rem, 24rem); margin-top: 1rem; }
20
+ .mae {
21
+ text-align: center; font-size: 0.85rem; color: #333;
22
+ margin-bottom: 0.2rem; letter-spacing: 0.1em;
23
+ }
24
+ #venue-floor {
25
+ position: relative; aspect-ratio: 1 / 1; width: 100%;
26
+ border: 3px solid #000; background: #fff; cursor: crosshair;
27
+ touch-action: none;
28
+ }
29
+ .shape-row { margin: 0.75rem 0; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
30
+ .shape-row label { font-size: 0.9rem; }
31
+ .shape-row select { font: inherit; padding: 0.2rem 0.4rem; }
32
+ .pin-wrap {
33
+ position: absolute; left: 0; top: 0; display: flex; flex-direction: column; align-items: center;
34
+ transform: translate(-50%, -100%); margin-top: -0.3rem; pointer-events: none;
35
+ white-space: nowrap; visibility: hidden;
36
+ }
37
+ .pin-wrap.is-visible { visibility: visible; }
38
+ .label-kokohen { font-size: 0.75rem; color: #c00; font-weight: 600; }
39
+ .label-arrow { font-size: 0.85rem; line-height: 1; color: #c00; }
40
+ .meeting-point {
41
+ width: 12px; height: 12px; border-radius: 50%;
42
+ background: #c00; box-shadow: 0 0 0 1px #fff, 0 0 0 2px #c00; margin-top: 0.1rem; flex-shrink: 0;
43
+ }
44
+ .copy-row { margin: 0.75rem 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
45
+ .copy-row button {
46
+ font: inherit; padding: 0.35rem 0.75rem; cursor: pointer;
47
+ border: 1px solid #333; border-radius: 6px; background: #fff;
48
+ }
49
+ .copy-row button:hover { background: #f3f3f3; }
50
+ #copy-url-status { font-size: 0.85rem; color: #0a0; min-height: 1.2em; }
51
+ /* シネマティック(LP 断片風) */
52
+ #cinematic-root {
53
+ position: fixed; inset: 0; z-index: 100000; pointer-events: none;
54
+ display: flex; align-items: center; justify-content: center;
55
+ font-family: "Syne", "Inter", system-ui, sans-serif; color: #f2ece4;
56
+ visibility: hidden; opacity: 0; transition: opacity 0.35s ease, visibility 0.35s;
57
+ }
58
+ #cinematic-root.is-open { visibility: visible; opacity: 1; pointer-events: auto; }
59
+ .cinematic__dim {
60
+ position: absolute; inset: 0; background: #030303; opacity: 0; transition: opacity 0.45s ease;
61
+ box-shadow: inset 0 0 120px rgba(0,0,0,0.9);
62
+ }
63
+ .cinematic__dim.is-on { opacity: 1; }
64
+ .cinematic__stage {
65
+ position: relative; z-index: 1; width: 100%; max-width: 32rem; min-height: 60vh; padding: 2rem 1.5rem;
66
+ display: flex; flex-direction: column; align-items: stretch; justify-content: center;
67
+ overflow: hidden;
68
+ }
69
+ .cinematic__beat {
70
+ position: absolute; left: 1.5rem; right: 1.5rem; text-align: left;
71
+ font-weight: 800; line-height: 0.95; letter-spacing: -0.04em; opacity: 0; will-change: transform, opacity;
72
+ }
73
+ .cinematic__beat--title { font-size: clamp(2.2rem, 9vw, 3.5rem); top: 22%; }
74
+ .cinematic__beat--venue { font-size: clamp(1.6rem, 6vw, 2.4rem); top: 38%; }
75
+ @keyframes cine-in-left {
76
+ from { opacity: 0; transform: translate3d(-12%, 0, 0) skewX(-2deg); }
77
+ 70% { opacity: 1; transform: translate3d(2%, 0, 0) skewX(0deg); }
78
+ to { opacity: 1; transform: translate3d(0, 0, 0); }
79
+ }
80
+ @keyframes cine-out-left {
81
+ to { opacity: 0; transform: translate3d(18%, 0, 0) skewX(3deg); }
82
+ }
83
+ @keyframes cine-in-right {
84
+ from { opacity: 0; transform: translate3d(14%, 0, 0) skewX(3deg); }
85
+ 70% { opacity: 1; transform: translate3d(-1%, 0, 0); }
86
+ to { opacity: 1; transform: translate3d(0, 0, 0); }
87
+ }
88
+ @keyframes cine-out-right {
89
+ to { opacity: 0; transform: translate3d(-16%, 0, 0); }
90
+ }
91
+ .cinematic__beat.is-in--title { animation: cine-in-left 0.75s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
92
+ .cinematic__beat.is-out--title { animation: cine-out-left 0.45s ease-in forwards; }
93
+ .cinematic__beat.is-in--venue { animation: cine-in-right 0.75s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
94
+ .cinematic__beat.is-out--venue { animation: cine-out-right 0.45s ease-in forwards; }
95
+ .cinematic__dot {
96
+ position: absolute; left: 50%; top: 50%; width: min(32vmin, 10rem); height: min(32vmin, 10rem);
97
+ margin: calc(min(32vmin, 10rem) / -2) 0 0 calc(min(32vmin, 10rem) / -2);
98
+ border-radius: 50%; background: radial-gradient(circle at 40% 35%, #ff2a4a, #8a0000 62%, #1a0000 100%);
99
+ box-shadow: 0 0 0 1px #fff, 0 0 80px rgba(255,0,40,0.5), 0 0 140px rgba(255,0,60,0.25);
100
+ opacity: 0; pointer-events: none;
101
+ }
102
+ @keyframes cine-pulse {
103
+ 0% { transform: scale(0.25); opacity: 0.4; }
104
+ 40% { transform: scale(1.15); opacity: 1; }
105
+ 100% { transform: scale(0.88); opacity: 0.92; }
106
+ }
107
+ .cinematic__dot.is-pulsing { animation: cine-pulse 0.52s ease-in-out 3; opacity: 1; }
108
+ .cinematic__final {
109
+ position: relative; z-index: 2; width: 100%;
110
+ border-left: 4px solid #e11; padding-left: 1.25rem; padding-top: 0.5rem; padding-bottom: 1rem;
111
+ opacity: 0; transform: translate3d(0, 1rem, 0) skewY(0.3deg);
112
+ transition: opacity 0.55s ease 0.1s, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.1s;
113
+ }
114
+ .cinematic__final.is-visible { opacity: 1; transform: translate3d(0, 0, 0) skewY(0); }
115
+ .cinematic__final kbd {
116
+ display: block; font-size: 0.65rem; font-family: "Space Mono", ui-monospace, monospace;
117
+ text-transform: uppercase; letter-spacing: 0.2em; color: #888; margin-bottom: 0.5rem;
118
+ }
119
+ .cinematic__row { margin: 0.6rem 0 1.1rem; }
120
+ .cinematic__row--venue .cinematic__final-venue { font-size: clamp(1.8rem, 6vw, 2.5rem); font-weight: 800; letter-spacing: -0.03em; line-height: 1.1; }
121
+ .cinematic__row-label { font-size: 0.7rem; letter-spacing: 0.15em; text-transform: uppercase; color: #6a6a6a; margin-bottom: 0.15rem; }
122
+ .cinematic__row-body { font-size: 1rem; font-weight: 600; }
123
+ .cinematic__row-body--mono { font-family: "Space Mono", ui-monospace, monospace; font-size: 0.95rem; color: #c8beb4; }
124
+ .cinematic__back {
125
+ margin-top: 1.5rem; align-self: flex-start; cursor: pointer;
126
+ font: 600 0.8rem "Syne", sans-serif; letter-spacing: 0.2em; text-transform: uppercase;
127
+ padding: 0.65rem 1.2rem; background: transparent; color: #f2ece4;
128
+ border: 1px solid #444; border-radius: 0; transition: background 0.2s, color 0.2s, border-color 0.2s;
129
+ }
130
+ .cinematic__back:hover, .cinematic__back:focus-visible { background: #e11; border-color: #e11; color: #fff; outline: none; }
131
+ @media (prefers-reduced-motion: reduce) {
132
+ .cinematic__beat, .cinematic__dot { animation: none !important; }
133
+ .cinematic__beat--title, .cinematic__beat--venue { opacity: 1 !important; transform: none !important; }
134
+ .cinematic__dot { opacity: 0.9; transform: scale(0.5); }
135
+ }
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <h1><%= title %></h1>
140
+ <div class="title-bar" id="page-venue-title">場所:えー</div>
141
+ <div class="shape-row">
142
+ <label for="venue-shape">会場 (shape)</label>
143
+ <select id="venue-shape" aria-label="会場">
144
+ <option value="room_a">roomA / えー</option>
145
+ <option value="room_b">roomB / びー</option>
146
+ <option value="room_c">roomC / しー</option>
147
+ <option value="room_main">roomMain / めいんありーな</option>
148
+ </select>
149
+ </div>
150
+ <div class="copy-row">
151
+ <button type="button" id="copy-url-button">待ち合わせURLをコピー</button>
152
+ <span id="copy-url-status" class="copy-url-status" aria-live="polite"></span>
153
+ </div>
154
+ <div class="venue-block">
155
+ <div class="mae">まえ</div>
156
+ <div id="venue-floor" role="img" aria-label="会場フロア。クリックで点を打ちます。">
157
+ <div id="pin-wrap" class="pin-wrap" aria-hidden="true">
158
+ <span class="label-kokohen">このへん</span>
159
+ <span class="label-arrow">↓</span>
160
+ <div class="meeting-point"></div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ <div id="cinematic-root" class="cinematic" aria-hidden="true" aria-modal="true" role="dialog" inert>
165
+ <div class="cinematic__dim" aria-hidden="true"></div>
166
+ <div class="cinematic__stage">
167
+ <h2 class="cinematic__beat cinematic__beat--title" id="cin-beat-title"></h2>
168
+ <p class="cinematic__beat cinematic__beat--venue" id="cin-beat-venue"></p>
169
+ <div class="cinematic__dot" id="cin-dot" role="presentation"></div>
170
+ <div class="cinematic__final" id="cin-final" aria-hidden="true">
171
+ <div class="cinematic__row">
172
+ <span class="cinematic__row-label">area</span>
173
+ <p class="cinematic__row-body" id="cin-area-body"></p>
174
+ </div>
175
+ <div class="cinematic__row">
176
+ <span class="cinematic__row-label">position</span>
177
+ <p class="cinematic__row-body cinematic__row-body--mono" id="cin-xy-body"></p>
178
+ </div>
179
+ <div class="cinematic__row cinematic__row--venue">
180
+ <span class="cinematic__row-label">会場</span>
181
+ <p class="cinematic__final-venue" id="cin-venue-body"></p>
182
+ </div>
183
+ <button type="button" class="cinematic__back" id="cin-back">戻る</button>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ <script src="https://cdn.jsdelivr.net/npm/@ruby/4.0-wasm-wasi@latest/dist/browser.script.iife.js"></script>
188
+ <script>
189
+ (function () {
190
+ var $ = function (id) { return document.getElementById(id); };
191
+ var root = function () { return $("cinematic-root"); };
192
+ var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
193
+ var escHandler = null;
194
+ function closeCinematic() {
195
+ if (escHandler) { document.removeEventListener("keydown", escHandler); escHandler = null; }
196
+ var el = root();
197
+ if (!el) return;
198
+ var dim = el.querySelector(".cinematic__dim");
199
+ var title = $("cin-beat-title");
200
+ var venue = $("cin-beat-venue");
201
+ var dot = $("cin-dot");
202
+ var final = $("cin-final");
203
+ if (final) { final.classList.remove("is-visible"); final.setAttribute("aria-hidden", "true"); }
204
+ if (title) { title.className = "cinematic__beat cinematic__beat--title"; title.textContent = ""; }
205
+ if (venue) { venue.className = "cinematic__beat cinematic__beat--venue"; venue.textContent = ""; }
206
+ if (dot) { dot.classList.remove("is-pulsing"); }
207
+ if (dim) dim.classList.remove("is-on");
208
+ el.classList.remove("is-open");
209
+ el.setAttribute("aria-hidden", "true");
210
+ el.setAttribute("inert", "");
211
+ document.body.style.overflow = "";
212
+ }
213
+ function fillFinal(p) {
214
+ var area = $("cin-area-body");
215
+ var xy = $("cin-xy-body");
216
+ var vn = $("cin-venue-body");
217
+ if (area) area.textContent = p.areaLabel != null ? p.areaLabel : "白フロア(会場図内)";
218
+ if (xy) {
219
+ var x = typeof p.x === "number" ? p.x : parseFloat(p.x) || 0.5;
220
+ var y = typeof p.y === "number" ? p.y : parseFloat(p.y) || 0.5;
221
+ xy.textContent = "x = " + x.toFixed(4) + " · y = " + y.toFixed(4);
222
+ }
223
+ if (vn) vn.textContent = p.venueName != null ? p.venueName : "—";
224
+ }
225
+ function bindBack() {
226
+ var b = $("cin-back");
227
+ if (!b) return;
228
+ function h() { closeCinematic(); b.removeEventListener("click", h); }
229
+ b.addEventListener("click", h);
230
+ }
231
+ window.sujikoRunCinematic = async function sujikoRunCinematic(p) {
232
+ if (!p) return;
233
+ var el = root();
234
+ if (!el) return;
235
+ if (el.classList.contains("is-open")) return;
236
+ var dim = el.querySelector(".cinematic__dim");
237
+ var title = $("cin-beat-title");
238
+ var venueB = $("cin-beat-venue");
239
+ var dot = $("cin-dot");
240
+ var final = $("cin-final");
241
+ if (!title || !venueB || !dot || !final) return;
242
+ escHandler = function (e) {
243
+ if (e.key === "Escape") { e.preventDefault(); closeCinematic(); }
244
+ };
245
+ document.addEventListener("keydown", escHandler);
246
+ var titleText = p.title != null ? String(p.title) : "Sujiko";
247
+ var venueName = p.venueName != null ? String(p.venueName) : "—";
248
+ el.classList.add("is-open");
249
+ el.removeAttribute("inert");
250
+ el.setAttribute("aria-hidden", "false");
251
+ document.body.style.overflow = "hidden";
252
+ if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
253
+ if (dim) dim.classList.add("is-on");
254
+ fillFinal(p);
255
+ final.classList.add("is-visible");
256
+ final.setAttribute("aria-hidden", "false");
257
+ var back = $("cin-back");
258
+ if (back) back.focus();
259
+ bindBack();
260
+ return;
261
+ }
262
+ if (dim) dim.classList.add("is-on");
263
+ await sleep(120);
264
+ title.textContent = titleText;
265
+ title.classList.add("is-in--title");
266
+ await sleep(800);
267
+ title.classList.remove("is-in--title");
268
+ title.classList.add("is-out--title");
269
+ await sleep(400);
270
+ title.classList.remove("is-out--title");
271
+ title.textContent = "";
272
+ title.className = "cinematic__beat cinematic__beat--title";
273
+ venueB.textContent = "場所:" + venueName;
274
+ venueB.classList.add("is-in--venue");
275
+ await sleep(820);
276
+ venueB.classList.remove("is-in--venue");
277
+ venueB.classList.add("is-out--venue");
278
+ await sleep(400);
279
+ venueB.classList.remove("is-out--venue");
280
+ venueB.textContent = "";
281
+ venueB.className = "cinematic__beat cinematic__beat--venue";
282
+ dot.classList.add("is-pulsing");
283
+ await sleep(1700);
284
+ dot.classList.remove("is-pulsing");
285
+ fillFinal(p);
286
+ final.classList.add("is-visible");
287
+ final.setAttribute("aria-hidden", "false");
288
+ await sleep(80);
289
+ var back2 = $("cin-back");
290
+ if (back2) back2.focus();
291
+ bindBack();
292
+ };
293
+ })();
294
+ </script>
295
+ <script type="text/ruby">
296
+ require "js"
297
+ require "json"
298
+ APP_TITLE = <%= title.inspect %>
299
+ COPY_PUBLIC_ORIGIN = <%= copy_public_origin_ruby %>
300
+ # tmp/tmp.md: 正規化座標・URL・会場定義
301
+ VENUE_LABELS = {
302
+ "room_a" => "えー",
303
+ "room_b" => "びー",
304
+ "room_c" => "しー",
305
+ "room_main" => "めいんありーな"
306
+ }
307
+ def normalize_shape_key(raw)
308
+ s = raw.to_s.strip.downcase
309
+ s = s.delete("^a-z0-9")
310
+ case s
311
+ when "square", "rooma", "rubykaigia", ""
312
+ "room_a"
313
+ when "roomb", "rubykaigib"
314
+ "room_b"
315
+ when "roomc", "rubykaigic"
316
+ "room_c"
317
+ when "roommain", "mainarena"
318
+ "room_main"
319
+ when "room_a", "room_b", "room_c", "room_main"
320
+ s
321
+ else
322
+ "room_a"
323
+ end
324
+ end
325
+ def url_shape_value(key)
326
+ case key
327
+ when "room_a" then "roomA"
328
+ when "room_b" then "roomB"
329
+ when "room_c" then "roomC"
330
+ when "room_main" then "roomMain"
331
+ else "roomA"
332
+ end
333
+ end
334
+ def nfloat(v, default = 0.5)
335
+ f = Float(v)
336
+ f = f.nan? || f.infinite? ? default : f
337
+ [[f, 0.0].max, 1.0].min
338
+ rescue ArgumentError, TypeError
339
+ default
340
+ end
341
+ $window = JS.global
342
+ $doc = $window[:document]
343
+ def fnum(x)
344
+ return 0.0 if x.nil?
345
+ Float(String(x).to_s)
346
+ rescue ArgumentError, TypeError
347
+ 0.0
348
+ end
349
+ def event_client_xy(e)
350
+ return [0.0, 0.0] if e.nil?
351
+ [fnum(e[:clientX]), fnum(e[:clientY])]
352
+ end
353
+ def rect_size(el)
354
+ r = el.getBoundingClientRect
355
+ { l: fnum(r[:left]), t: fnum(r[:top]), w: fnum(r[:width]), h: fnum(r[:height]) }
356
+ end
357
+ def read_query
358
+ s = String($window[:location][:search] || "")
359
+ s = s[1, s.size] if s[0] == "?"
360
+ h = {}
361
+ s.split("&").each do |pair|
362
+ k, v = pair.split("=", 2)
363
+ next if k.to_s.empty?
364
+ h[k] = (v or "")
365
+ end
366
+ h
367
+ end
368
+ def copy_url_origin
369
+ w = $window
370
+ if !COPY_PUBLIC_ORIGIN.nil? && !String(COPY_PUBLIC_ORIGIN).strip.empty?
371
+ return String(COPY_PUBLIC_ORIGIN).strip
372
+ end
373
+ String(w[:location][:origin])
374
+ end
375
+ def build_full_share_url(shape_key, x, y)
376
+ qx = format("%.4f", nfloat(x, 0.5))
377
+ qy = format("%.4f", nfloat(y, 0.5))
378
+ sv = url_shape_value(shape_key)
379
+ origin = copy_url_origin
380
+ path = String($window[:location][:pathname] || "/")
381
+ origin + path + "?" + "shape=" + sv + "&x=" + qx + "&y=" + qy
382
+ end
383
+ def copy_to_clipboard(text)
384
+ t = $doc.createElement("textarea")
385
+ t[:value] = text
386
+ st = t[:style]
387
+ st[:position] = "fixed"
388
+ st[:left] = "0"
389
+ st[:top] = "0"
390
+ st[:opacity] = "0"
391
+ b = $doc[:body]
392
+ b.appendChild(t)
393
+ t[:focus] rescue t.call(:focus) rescue nil
394
+ t.call(:select) rescue t.select
395
+ r = $doc.call(:execCommand, "copy")
396
+ b.removeChild(t)
397
+ r
398
+ end
399
+ def set_copy_status(status_el, message)
400
+ return unless status_el
401
+ status_el[:textContent] = message
402
+ w = $window
403
+ t = 2200
404
+ JS.global.setTimeout(-> { status_el[:textContent] = "" }, t) if message != ""
405
+ nil
406
+ end
407
+ def update_venue_title(el, shape_key)
408
+ name = VENUE_LABELS[shape_key] || "えー"
409
+ el[:textContent] = "場所:" + name
410
+ end
411
+ def set_pin(wrap, xN, yN, show)
412
+ st = wrap[:style]
413
+ st[:left] = (xN * 100.0).to_s + "%"
414
+ st[:top] = (yN * 100.0).to_s + "%"
415
+ if show
416
+ wrap.setAttribute("class", "pin-wrap is-visible")
417
+ wrap.setAttribute("aria-hidden", "false")
418
+ else
419
+ wrap.setAttribute("class", "pin-wrap")
420
+ wrap.setAttribute("aria-hidden", "true")
421
+ end
422
+ nil
423
+ end
424
+ def on_floor_click(floor_el, wrap, sel, title_el, e)
425
+ x, y = event_client_xy(e)
426
+ z = rect_size(floor_el)
427
+ rw = [z[:w], 0.0001].max
428
+ rh = [z[:h], 0.0001].max
429
+ xN = nfloat((x - z[:l]) / rw, 0.5)
430
+ yN = nfloat((y - z[:t]) / rh, 0.5)
431
+ key = String(sel[:value])
432
+ set_pin(wrap, xN, yN, true)
433
+ update_venue_title(title_el, key)
434
+ $share_x = xN
435
+ $share_y = yN
436
+ end
437
+ def on_copy_url(sel, status_el)
438
+ key = String(sel[:value])
439
+ x = nfloat($share_x, 0.5) rescue 0.5
440
+ y = nfloat($share_y, 0.5) rescue 0.5
441
+ url = build_full_share_url(key, x, y)
442
+ ok = copy_to_clipboard(url)
443
+ set_copy_status(status_el, ok ? "コピーしました" : "コピーに失敗しました")
444
+ if ok
445
+ vn = VENUE_LABELS[key] || "えー"
446
+ pl = { "title" => APP_TITLE, "venueName" => vn, "x" => x, "y" => y, "areaLabel" => "白フロア(会場図の黒枠内)" }
447
+ $window.call(:eval, "sujikoRunCinematic(#{JSON.generate(pl)})")
448
+ end
449
+ nil
450
+ end
451
+ $share_x = 0.5
452
+ $share_y = 0.5
453
+ fl = $doc.getElementById("venue-floor")
454
+ w = $doc.getElementById("pin-wrap")
455
+ sel = $doc.getElementById("venue-shape")
456
+ title_el = $doc.getElementById("page-venue-title")
457
+ copy_btn = $doc.getElementById("copy-url-button")
458
+ status_el = $doc.getElementById("copy-url-status")
459
+ if fl && w && sel && title_el && copy_btn && status_el
460
+ q = read_query
461
+ k = normalize_shape_key(q["shape"] || "roomA")
462
+ sel[:value] = k
463
+ update_venue_title(title_el, k)
464
+ x0 = nfloat(q["x"] || 0.5, 0.5)
465
+ y0 = nfloat(q["y"] || 0.5, 0.5)
466
+ if q["x"] && q["y"] && !q["x"].to_s.empty? && !q["y"].to_s.empty?
467
+ $share_x = x0
468
+ $share_y = y0
469
+ set_pin(w, x0, y0, true)
470
+ end
471
+ fl.addEventListener("click", proc { |e| on_floor_click(fl, w, sel, title_el, e) })
472
+ sel.addEventListener("change", proc {
473
+ k2 = String(sel[:value])
474
+ update_venue_title(title_el, k2)
475
+ })
476
+ copy_btn.addEventListener("click", proc { on_copy_url(sel, status_el) })
477
+ puts "ruby.wasm: #{RUBY_VERSION} (会場+点 → コピーで ?shape&x&y 付きURL)"
478
+ else
479
+ warn "必須要素が足りません (venue-floor / pin-wrap / venue-shape / page-venue-title / copy 周り)"
480
+ end
481
+ </script>
482
+ </body>
483
+ </html>
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sujiko
4
+ VERSION = "0.1.0"
5
+ end
data/lib/sujiko.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sujiko/version"
4
+ require_relative "sujiko/server"
5
+
6
+ module Sujiko
7
+ class Error < StandardError; end
8
+ end
data/sig/sujiko.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Sujiko
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sujiko
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tutuitakumi
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |-
14
+ Sujiko is a joke / toy Ruby gem: a small TCP server for local development, not for serious or
15
+ production use. It serves one page: a venue floor plan where
16
+ a meetup point is shown. Open GET / with optional query parameters shape, x, and y—the same
17
+ contract as a Rails Spots-style app and iOS: shape selects the room (e.g. roomA, with
18
+ normalization to internal ids like room_a); x and y are normalized coordinates from 0.0 to 1.0
19
+ (top-left of the white floor, independent of device pixels). Use it to preview map UI and to
20
+ build or verify share URLs (Safari, copy, etc.) before deploying.
21
+ email:
22
+ - takumi.github@gmail.com
23
+ executables:
24
+ - sujiko
25
+ extensions: []
26
+ extra_rdoc_files: []
27
+ files:
28
+ - LICENSE.txt
29
+ - README.md
30
+ - Rakefile
31
+ - exe/sujiko
32
+ - lib/sujiko.rb
33
+ - lib/sujiko/server.rb
34
+ - lib/sujiko/templates/index.html.erb
35
+ - lib/sujiko/version.rb
36
+ - sig/sujiko.rbs
37
+ homepage: https://github.com/tutuitakumi/sujiko
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ allowed_push_host: https://rubygems.org
42
+ homepage_uri: https://github.com/tutuitakumi/sujiko
43
+ source_code_uri: https://github.com/tutuitakumi/sujiko
44
+ changelog_uri: https://github.com/tutuitakumi/sujiko
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.1.0
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.3.7
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: 'Joke/toy gem: local dev server for a venue meetup map (GET /?shape&x&y).
64
+ Not for production.'
65
+ test_files: []