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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +77 -0
- data/Rakefile +4 -0
- data/exe/sujiko +22 -0
- data/lib/sujiko/server.rb +188 -0
- data/lib/sujiko/templates/index.html.erb +483 -0
- data/lib/sujiko/version.rb +5 -0
- data/lib/sujiko.rb +8 -0
- data/sig/sujiko.rbs +4 -0
- metadata +65 -0
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
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>
|
data/lib/sujiko.rb
ADDED
data/sig/sujiko.rbs
ADDED
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: []
|