summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTor Andersson <tor@ccxvii.net>2023-10-17 18:26:13 +0200
committerTor Andersson <tor@ccxvii.net>2023-10-20 22:34:41 +0200
commit4d0d67cefff209d82fad25ce8a44ad310b986ed0 (patch)
tree4f434942735ea9658f4d6eb05777dc1ef2524e9b
parent78a3447cc5766133a8757998d41e4187a1811c20 (diff)
downloadserver-4d0d67cefff209d82fad25ce8a44ad310b986ed0.tar.gz
Mobile phone interface rework.
* Redesign responsive layout. * Pan & Zoom with touch (and scroll wheel). Scale main area using transforms and scroll offsets. Toggle zoom button scales map on large screens, full main zoom on small screens. * Automated standard toolbar buttons (remove from play.html files).
-rw-r--r--NOTES.md45
-rw-r--r--public/common/columbia.css19
-rw-r--r--public/common/play.css225
-rw-r--r--public/common/play.js492
-rw-r--r--public/common/replay.js4
-rw-r--r--public/style.css25
6 files changed, 602 insertions, 208 deletions
diff --git a/NOTES.md b/NOTES.md
index b9dd646..e5771ff 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -1,18 +1,57 @@
-Icons are sourced from various places:
+# Icons are sourced from various places:
* https://game-icons.net/
* https://commons.wikimedia.org/wiki/Main_Page
-Fonts:
+# Fonts:
* https://github.com/adobe-fonts/source-sans
* https://github.com/adobe-fonts/source-serif
* https://www.google.com/get/noto/
-Image processing software:
+# Image processing software:
* https://github.com/google/guetzli/
* https://github.com/mozilla/mozjpeg
* https://github.com/svg/svgo
* http://optipng.sourceforge.net/
* http://potrace.sourceforge.net/
+
+# Mobile responsive design:
+
+* Image resolutions
+
+ Map is 75dpi unless very small.
+ Counters are same dpi as map.
+
+ Cards are 100dpi if text is small.
+ Cards are 75dpi if usually placed on map.
+ If rarely placed on map, keep at 100dpi and scale(0.75).
+
+ @media (min-resolution: 97dpi)
+ Use 2x resolution map and card
+
+ Counters always use @2x resolution if shift-zooming is available.
+
+* Touch screen
+
+ @media (pointer: coarse)
+ increase tiny UI element sizes (for example the replay buttons)
+
+* Screen size thresholds for layout triggers:
+
+ @media (max-width: 400)
+ one-column tabbed mode
+
+ @media (max-width: 800)
+ mobile phone layout
+ two-column tabbed mode (notepad and chat window fill screen)
+ horizontally scroll basic content; use full map width for hands etc
+
+ @media (max-height: 600)
+ mobile phone landscape layout
+ start hiding player names behind tap/hover
+ hide or reduce turn info, role info, and current card
+
+ @media (max-height: 800)
+ small laptop screen
diff --git a/public/common/columbia.css b/public/common/columbia.css
index 7b6f78e..030e079 100644
--- a/public/common/columbia.css
+++ b/public/common/columbia.css
@@ -1,10 +1,10 @@
/* BATTLE DIALOG FOR COLUMBIA BLOCK GAMES */
#battle {
- position: fixed;
+ position: absolute;
min-width: 524px; /* 6 blocks wide */
left: 12px;
- top: 56px;
+ top: 12px;
z-index: 50;
box-shadow: 0px 5px 10px 0px rgba(0,0,0,0.5);
background-color: white;
@@ -87,18 +87,3 @@
.battle_menu.storm .action.storm { display: block; }
.battle_menu.sally .action.sally { display: block; }
.battle_menu.hit .action.hit { display: block; }
-
-/* MOBILE PHONE LAYOUT */
-
-@media (max-width: 640px) {
- #battle {
- position: static;
- grid-column: 1;
- grid-row: 3;
- min-width: auto !important;
- box-shadow: none;
- border-top: none;
- border-left: none;
- border-right: none;
- }
-}
diff --git a/public/common/play.css b/public/common/play.css
index 2166a97..d943146 100644
--- a/public/common/play.css
+++ b/public/common/play.css
@@ -43,6 +43,7 @@ button {
background-color: gainsboro;
border: 2px solid;
outline: 1px solid black;
+ white-space: nowrap;
}
button:disabled {
@@ -68,41 +69,20 @@ body {
display: grid;
overflow: clip;
grid-template-columns: minmax(0, 1fr) auto;
- grid-template-rows: auto minmax(0, 1fr);
- width: 100vw;
- height: 100vh;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ width: 100dvw;
+ height: 100dvh;
}
header {
grid-column: 1/3;
grid-row: 1;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- border-bottom: 1px solid black;
- background-color: gainsboro;
-}
-
-#toolbar {
- display: flex;
- flex-wrap: wrap;
-}
-
-header.disconnected {
- background-color: red !important;
-}
-
-header.your_turn {
- background-color: orange;
-}
-
-header.replay {
- background-image: repeating-linear-gradient(45deg, gainsboro, gainsboro 40px, silver 40px, silver 80px);
}
main {
+ position: relative;
grid-column: 1;
- grid-row: 2;
+ grid-row: 2/4;
overflow: auto;
scrollbar-width: none;
}
@@ -112,7 +92,7 @@ aside {
grid-row: 2;
display: grid;
overflow: clip;
- grid-template-rows: auto minmax(0, 1fr) auto;
+ grid-template-rows: auto minmax(0, 1fr);
width: 212px;
border-left: 1px solid black;
}
@@ -124,7 +104,7 @@ aside {
#turn_info {
border-bottom: 1px solid black;
- padding: 8px;
+ padding: 4px 8px;
white-space: pre-line;
font-style: italic;
font-size: 12px;
@@ -232,25 +212,41 @@ a.menu_item {
filter: invert(100%);
}
-header #actions {
+header {
display: flex;
flex-wrap: wrap;
- justify-content: end;
- gap: 8px;
- padding: 0 8px;
- margin: 4px 0;
+ align-items: center;
+ gap: 0 8px;
+ border-bottom: 1px solid black;
+ background-color: gainsboro;
}
-header #viewpoint_panel {
+#toolbar {
+ display: flex;
+}
+
+header.disconnected {
+ background-color: red !important;
+}
+
+header.your_turn {
+ background-color: orange;
+}
+
+header.replay {
+ background-image: repeating-linear-gradient(45deg, gainsboro, gainsboro 40px, silver 40px, silver 80px);
+}
+
+#actions, #viewpoint_panel {
display: flex;
- flex-wrap: wrap;
justify-content: end;
- padding: 0 8px;
- margin: 4px 0;
+ align-items: center;
}
-header .viewpoint_button {
- margin-right: 0;
+#actions {
+ flex-wrap: wrap;
+ padding: 4px 8px;
+ gap: 8px;
}
header .viewpoint_button.selected {
@@ -263,7 +259,7 @@ header .viewpoint_button.selected:active:hover {
}
#prompt {
- padding-left: 20px;
+ padding-left: 12px;
font-size: 18px;
flex: 1 1 200px;
text-overflow: ellipsis;
@@ -272,9 +268,12 @@ header .viewpoint_button.selected:active:hover {
}
#replay_panel {
+ grid-column: 2;
+ grid-row: 3;
display: flex;
height: 24px;
border-top: 1px solid black;
+ border-left: 1px solid black;
background-color: silver;
}
@@ -368,16 +367,21 @@ header .viewpoint_button.selected:active:hover {
left: 24px;
top: 68px;
width: 640px;
+ z-index: 499;
}
#notepad_window {
left: 60px;
top: 200px;
+ height: auto;
+ z-index: 498;
}
#chat_window, #notepad_window {
position: fixed;
- z-index: 500;
+ display: grid;
+ grid-template-rows: min-content 1fr min-content;
+ grid-template-columns: 1fr 30px;
border: 1px solid black;
background-color: white;
box-shadow: 0px 4px 8px 0px rgba(0,0,0,0.5);
@@ -389,6 +393,8 @@ header .viewpoint_button.selected:active:hover {
}
#chat_header, #notepad_header {
+ grid-row: 1;
+ grid-column: 1/3;
user-select: none;
cursor: move;
background-color: gainsboro;
@@ -397,12 +403,13 @@ header .viewpoint_button.selected:active:hover {
}
#chat_x, #notepad_x {
+ grid-row: 1;
+ grid-column: 2;
user-select: none;
cursor: pointer;
- position: absolute;
- right: 3px;
- top: 3px;
- padding: 0px 2px;
+ margin: 5px 5px;
+ height: 24px;
+ text-align: right;
}
#chat_x:hover, #notepad_x:hover {
@@ -411,10 +418,12 @@ header .viewpoint_button.selected:active:hover {
}
#chat_text, #notepad_input {
+ grid-row: 2;
+ grid-column: 1/3;
margin: 0;
font-size: 16px;
line-height: 24px;
- height: 216px;
+ min-height: 216px;
padding: 0px 4px;
overflow-y: scroll;
}
@@ -424,7 +433,8 @@ header .viewpoint_button.selected:active:hover {
}
#chat_form {
- display: block;
+ grid-row: 3;
+ grid-column: 1/3;
margin: 0;
padding: 0;
border-top: 1px solid black;
@@ -446,6 +456,8 @@ header .viewpoint_button.selected:active:hover {
}
#notepad_footer {
+ grid-row: 3;
+ grid-column: 1/3;
display: flex;
justify-content: end;
padding: 8px;
@@ -455,43 +467,72 @@ header .viewpoint_button.selected:active:hover {
/* MOBILE PHONE LAYOUT */
-@media (max-width: 640px) {
+#fullscreen_button {
+ display: none;
+}
+
+@media (pointer: coarse) and (max-width: 400px) {
+ #fullscreen_button { display: block; }
+ #zoom_button { display: none; }
+}
+
+@media (pointer: coarse) {
+ #replay_panel, .replay_button {
+ height: 36px;
+ }
+}
+
+@media (max-height: 600px) {
+ .role_name:not(:hover) .role_user {
+ display: none;
+ }
+}
+
+@media (max-width: 800px) {
body {
- width: 100%;
- height: auto;
- grid-template-columns: 1fr;
- grid-template-rows: 108px min-content;
- overflow-x: clip;
- overflow-y: auto;
+ grid-template-columns: 1fr min-content;
+ grid-template-rows: min-content 1fr min-content;
+ }
+ header {
+ grid-column: 1/3;
+ grid-row: 1;
+ }
+ main {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ aside {
+ grid-column: 2;
+ grid-row: 2/4;
}
footer {
- width: 100%;
- background-color: rgba(255,255,255,0.9);
+ background-color: #fffc;
}
- header.mobilefix {
- position: fixed;
- top: -40px;
+ #replay_panel {
+ grid-column: 1;
+ grid-row: 3;
+ border-left: none;
+ z-index: 496;
}
header {
- position: absolute;
- min-height: 108px;
- top: 0;
- left: 0;
- right: 0;
- display: block;
- z-index: 1000;
+ display: grid;
+ grid-template-columns: min-content auto;
+ }
+ #toolbar {
+ grid-row: 1;
+ grid-column: 1;
}
#prompt {
- padding-left: 8px;
- display: block;
+ grid-row: 2;
+ grid-column: 1/3;
+ padding: 0 8px 3px 8px;
+ }
+ #prompt:hover {
white-space: normal;
}
-
- #replay_panel {
- position: fixed;
- z-index: 500;
- bottom: 0;
- width: 100%;
+ #actions {
+ grid-row: 1;
+ grid-column: 2;
}
#chat_window, #notepad_window {
@@ -501,30 +542,32 @@ header .viewpoint_button.selected:active:hover {
display: none;
width: auto;
box-shadow: none;
- border-top: none;
+ border: none;
}
#chat_window.show, #notepad_window.show {
- display: block;
+ display: grid;
}
+}
- /* game specific dialogs in rows 3-9 */
+@media (max-width: 800px) { footer { bottom: 25px } }
+@media (pointer: coarse) and (max-width: 800px) { footer { bottom: 37px } }
- main {
- grid-column: 1;
- grid-row: 10;
+@media (max-width: 400px) {
+ body {
+ grid-template-columns: 1fr;
+ }
+ header {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto;
}
aside {
grid-column: 1;
- grid-row: 11;
- width: auto !important;
- border-left: none;
- border-top: 1px solid black;
- margin-bottom: 25px; /* space for replay panel */
- }
- #log {
- height: 50vh;
- }
- #replay_button, #rematch_button {
- display: inline-block;
+ grid-row: 2/3;
+ z-index: 497;
+ width: 100vw;
+ border: none;
}
+ #toolbar { grid-row: 1; grid-column: 1; }
+ #actions { grid-row: 2; grid-column: 1; }
+ #prompt { grid-row: 3; grid-column: 1; }
}
diff --git a/public/common/play.js b/public/common/play.js
index 73566bd..8aa1f65 100644
--- a/public/common/play.js
+++ b/public/common/play.js
@@ -135,19 +135,15 @@ function init_chat() {
let chat_window = document.createElement("div")
chat_window.id = "chat_window"
chat_window.innerHTML = `
- <div id="chat_x" onclick="toggle_chat()">\u274c</div>
<div id="chat_header">Chat</div>
+ <div id="chat_x" onclick="toggle_chat()">\u274c</div>
<div id="chat_text"></div>
<form id="chat_form" action=""><input id="chat_input" autocomplete="off"></form>
`
document.querySelector("body").appendChild(chat_window)
- let chat_button = document.createElement("div")
- chat_button.id = "chat_button"
- chat_button.className = "icon_button"
- chat_button.innerHTML = '<img src="/images/chat-bubble.svg">'
- chat_button.addEventListener("click", toggle_chat)
- document.querySelector("#toolbar").appendChild(chat_button)
+ let chat_button = document.getElementById("chat_button")
+ chat_button.classList.remove("hide")
chat = {
is_visible: false,
@@ -274,9 +270,9 @@ function init_notepad() {
let notepad_window = document.createElement("div")
notepad_window.id = "notepad_window"
notepad_window.innerHTML = `
- <div id="notepad_x" onclick="toggle_notepad()">\u274c</div>
<div id="notepad_header">Notepad: ${player}</div>
- <textarea id="notepad_input" cols="55" rows="20" maxlength="16000" oninput="dirty_notepad()"></textarea>
+ <div id="notepad_x" onclick="toggle_notepad()">\u274c</div>
+ <textarea id="notepad_input" cols="55" rows="10" maxlength="16000" oninput="dirty_notepad()"></textarea>
<div id="notepad_footer"><button id="notepad_save" onclick="save_notepad()" disabled>Save</button></div>
`
document.querySelector("body").appendChild(notepad_window)
@@ -338,7 +334,23 @@ function toggle_notepad() {
show_notepad()
}
-function add_icon_button(parent, id, img, title, fn) {
+window.addEventListener("keydown", (evt) => {
+ if (document.activeElement === document.getElementById("chat_input"))
+ return
+ if (document.activeElement === document.getElementById("notepad_input"))
+ return
+ if (evt.key === "Shift")
+ document.querySelector("body").classList.add("shift")
+})
+
+window.addEventListener("keyup", (evt) => {
+ if (evt.key === "Shift")
+ document.querySelector("body").classList.remove("shift")
+})
+
+/* REMATCH BUTTON */
+
+function add_icon_button(where, id, img, title, fn) {
let button = document.getElementById(id)
if (!button) {
button = document.createElement("div")
@@ -347,13 +359,14 @@ function add_icon_button(parent, id, img, title, fn) {
button.className = "icon_button"
button.innerHTML = '<img src="/images/' + img + '.svg">'
button.addEventListener("click", fn)
- parent.appendChild(button)
+ if (where)
+ document.getElementById("toolbar").appendChild(button)
+ else
+ document.querySelector(".menu").after(button)
}
return button
}
-/* REMATCH BUTTON */
-
function remove_resign_menu() {
document.querySelectorAll(".resign").forEach(x => x.remove())
}
@@ -370,9 +383,9 @@ function goto_replay() {
}
function on_game_over() {
- add_icon_button(document.querySelector("header"), "replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay)
+ add_icon_button(1, "replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay)
if (player !== "Observer")
- add_icon_button(document.querySelector("header"), "rematch_button", "cycle", "Propose a rematch!", goto_rematch)
+ add_icon_button(1, "rematch_button", "cycle", "Propose a rematch!", goto_rematch)
remove_resign_menu()
}
@@ -517,7 +530,7 @@ function connect_play() {
if (snap_count === 0)
replay_panel.remove()
else
- document.querySelector("aside").appendChild(replay_panel)
+ document.querySelector("body").appendChild(replay_panel)
console.log("SNAPSIZE", snap_count)
break
@@ -608,59 +621,6 @@ try {
window.addEventListener("resize", scroll_log_to_end)
}
-/* MAP ZOOM */
-
-function toggle_log() {
- document.querySelector("aside").classList.toggle("hide")
- zoom_map()
-}
-
-function toggle_zoom() {
- let mapwrap = document.getElementById("mapwrap")
- if (mapwrap) {
- mapwrap.classList.toggle("fit")
- zoom_map()
- }
-}
-
-function zoom_map() {
- let mapwrap = document.getElementById("mapwrap")
- if (mapwrap) {
- let main = document.querySelector("main")
- let map = document.getElementById("map")
- map.style.transform = null
- mapwrap.style.width = null
- mapwrap.style.height = null
- if (mapwrap.classList.contains("fit")) {
- let { width: gw, height: gh } = main.getBoundingClientRect()
- let { width: ww, height: wh } = mapwrap.getBoundingClientRect()
- let { width: cw, height: ch } = map.getBoundingClientRect()
- let scale = Math.min(ww / cw, gh / ch)
- if (scale < 1) {
- map.style.transform = "scale(" + scale + ")"
- mapwrap.style.width = (cw * scale) + "px"
- mapwrap.style.height = (ch * scale) + "px"
- }
- }
- }
-}
-
-window.addEventListener("resize", zoom_map)
-
-window.addEventListener("keydown", (evt) => {
- if (document.activeElement === document.getElementById("chat_input"))
- return
- if (document.activeElement === document.getElementById("notepad_input"))
- return
- if (evt.key === "Shift")
- document.querySelector("body").classList.add("shift")
-})
-
-window.addEventListener("keyup", (evt) => {
- if (evt.key === "Shift")
- document.querySelector("body").classList.remove("shift")
-})
-
/* ACTIONS */
function action_button_imp(action, label, callback) {
@@ -745,24 +705,6 @@ function send_restore() {
send_message("restore", window.localStorage[params.title_id + "/save"])
}
-/* MOBILE PHONE LAYOUT */
-
-let mobile_scroll_header = document.querySelector("header")
-let mobile_scroll_last_y = 0
-
-window.addEventListener("scroll", function scroll_mobile_fix (evt) {
- if (mobile_scroll_header.clientWidth <= 640) {
- if (window.scrollY > 40) {
- if (mobile_scroll_last_y <= 40)
- mobile_scroll_header.classList.add("mobilefix")
- } else {
- if (mobile_scroll_last_y > 40)
- mobile_scroll_header.classList.remove("mobilefix")
- }
- mobile_scroll_last_y = window.scrollY
- }
-})
-
/* REPLAY */
function init_replay() {
@@ -785,6 +727,11 @@ window.addEventListener("load", function () {
/* MAIN MENU */
+add_icon_button(0, "chat_button", "chat-bubble", "Open chat", toggle_chat).classList.add("hide")
+add_icon_button(0, "zoom_button", "magnifying-glass", "Zoom", () => toggle_zoom())
+add_icon_button(0, "log_button", "scroll-quill", "Hide log", toggle_log)
+add_icon_button(0, "fullscreen_button", "expand", "Fullscreen", toggle_fullscreen)
+
function init_main_menu() {
let popup = document.querySelector(".menu_popup")
let sep = document.createElement("div")
@@ -821,6 +768,13 @@ if (params.mode === "play" && params.role !== "Observer") {
add_main_menu_item_link("Go home", "/")
}
+function toggle_fullscreen() {
+ if (document.fullscreenElement)
+ document.exitFullscreen()
+ else
+ document.documentElement.requestFullscreen()
+}
+
/* SNAPSHOT VIEW */
var replay_panel = null
@@ -898,3 +852,367 @@ function on_snap_stop() {
on_update_log(game_log.length, game_log.length)
}
}
+
+/* TOGGLE ZOOM MAP TO FIT */
+
+function toggle_log() {
+ document.querySelector("aside").classList.toggle("hide")
+ zoom_map()
+}
+
+var toggle_zoom = function () {}
+
+function zoom_map() {
+ let mapwrap = document.getElementById("mapwrap")
+ if (mapwrap) {
+ let main = document.querySelector("main")
+ let map = document.getElementById("map")
+ map.style.transform = null
+ mapwrap.style.width = null
+ mapwrap.style.height = null
+ if (mapwrap.classList.contains("fit")) {
+ let { width: gw, height: gh } = main.getBoundingClientRect()
+ let { width: ww, height: wh } = mapwrap.getBoundingClientRect()
+ let { width: cw, height: ch } = map.getBoundingClientRect()
+ let scale = Math.min(ww / cw, gh / ch)
+ if (scale < 1) {
+ map.style.transform = "scale(" + scale + ")"
+ mapwrap.style.width = (cw * scale) + "px"
+ mapwrap.style.height = (ch * scale) + "px"
+ }
+ }
+ }
+}
+
+window.addEventListener("resize", zoom_map)
+
+/* PAN & ZOOM GAME BOARD */
+
+;(function panzoom_init() {
+ const MIN_ZOOM = 0.5
+ const MAX_ZOOM = 1.5
+ const THRESHOLD = 0.0625
+ const DECELERATION = 125
+
+ console.log("DPX", window.devicePixelRatio)
+
+ const e_scroll = document.querySelector("main")
+ e_scroll.style.touchAction = "none"
+
+ const e_inner = document.createElement("div")
+ e_inner.id = "pan_zoom_main"
+ e_inner.style.transformOrigin = "0 0"
+ e_inner.style.height = "120px"
+ while (e_scroll.firstChild)
+ e_inner.appendChild(e_scroll.firstChild)
+
+ const e_outer = document.createElement("div")
+ e_outer.id = "pan_zoom_wrap"
+ e_outer.style.height = "120px"
+ e_outer.appendChild(e_inner)
+
+ e_scroll.appendChild(e_outer)
+
+ const mapwrap = document.getElementById("mapwrap")
+ const map = document.getElementById("map") || e_inner.firstChild
+ const map_w = mapwrap ? mapwrap.clientWidth : map.clientWidth
+ const map_h = mapwrap ? mapwrap.clientHeight : map.clientHeight
+
+ console.log("MAP", map_w, map_h)
+
+ var transform0 = { x: 0, y: 0, scale: 1 }
+ var transform1 = { x: 0, y: 0, scale: 1 }
+ var old_scale = 1
+
+ // touch finger tracking
+ var last_touch_x = {}
+ var last_touch_y = {}
+ var last_touch_length = 0
+
+ // momentum velocity tracking
+ var mom_last_t = null
+ var mom_last_x = null
+ var mom_last_y = null
+
+ // momentum auto-scroll
+ var timer = 0
+ var mom_time = 0
+ var mom_vx = 0
+ var mom_vy = 0
+
+ try {
+ new ResizeObserver(update_transform_resize).observe(document.getElementById("log"))
+ } catch (err) {
+ window.addEventListener("resize", function (evt) {
+ old_scale = 0
+ update_transform()
+ })
+ }
+
+ function clamp_scale(scale) {
+ let win_w = e_scroll.clientWidth
+ let win_h = e_scroll.clientHeight
+ let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h)
+ if (scale * transform0.scale > MAX_ZOOM)
+ scale = MAX_ZOOM / transform0.scale
+ if (scale * transform0.scale < real_min_zoom)
+ scale = real_min_zoom / transform0.scale
+ return scale
+ }
+
+ function anchor_transform(touches) {
+ // in case it changed from outside
+ transform1.x = -e_scroll.scrollLeft
+ transform1.y = -e_scroll.scrollTop
+
+ transform0.scale = transform1.scale
+ transform0.x = transform1.x
+ transform0.y = transform1.y
+ if (touches) {
+ for (let touch of touches) {
+ last_touch_x[touch.identifier] = touch.clientX
+ last_touch_y[touch.identifier] = touch.clientY
+ }
+ last_touch_length = touches.length
+ } else {
+ last_touch_length = 0
+ }
+ }
+
+ function toggle_zoom_imp() {
+ if (transform1.scale === 1) {
+ if (window.innerWidth >= 800) {
+ if (mapwrap) {
+ mapwrap.classList.toggle("fit")
+ zoom_map()
+ return
+ }
+ }
+ let win_w = e_scroll.clientWidth
+ let win_h = e_scroll.clientHeight
+ let min_z = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h)
+ zoom_to(min_z)
+ } else {
+ zoom_to(1)
+ }
+ return false
+ }
+
+ function disable_map_fit() {
+ if (mapwrap && mapwrap.classList.contains("fit")) {
+ mapwrap.classList.remove("fit")
+ zoom_map()
+ }
+ }
+
+ function zoom_to(new_scale) {
+ let cx = e_scroll.clientWidth / 2
+ let cy = 0
+
+ // in case changed from outside
+ transform1.x = -e_scroll.scrollLeft
+ transform1.y = -e_scroll.scrollTop
+
+ transform1.x -= cx
+ transform1.y -= cy
+ transform1.x *= new_scale / transform1.scale
+ transform1.y *= new_scale / transform1.scale
+ transform1.scale = new_scale
+ transform1.x += cx
+ transform1.y += cy
+
+ update_transform()
+ }
+
+ function update_transform() {
+ let win_w = e_scroll.clientWidth
+ let win_h = e_scroll.clientHeight
+
+ // clamp zoom
+ let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h)
+ transform1.scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale))
+
+ e_scroll.scrollLeft = -transform1.x
+ e_scroll.scrollTop = -transform1.y
+
+ if (transform1.scale !== old_scale) {
+ if (transform1.scale === 1) {
+ e_inner.style.transform = null
+ } else {
+ e_inner.style.transform = `scale(${transform1.scale})`
+ disable_map_fit()
+ }
+ e_inner.style.width = (win_w / transform1.scale) + "px"
+ e_outer.style.width = (e_inner.clientWidth * transform1.scale) + "px"
+ old_scale = transform1.scale
+ }
+ }
+
+ function start_measure(time) {
+ mom_last_t = [ time, time, time ]
+ mom_last_x = [ transform1.x, transform1.x, transform1.x ]
+ mom_last_y = [ transform1.y, transform1.y, transform1.y ]
+ }
+
+ function abort_measure(time) {
+ mom_last_t = mom_last_x = mom_last_y = null
+ }
+
+ function move_measure(time) {
+ if (mom_last_t) {
+ mom_last_t[0] = time
+ mom_last_x[0] = transform1.x
+ mom_last_y[0] = transform1.y
+ if (mom_last_t[0] - mom_last_t[1] > 15) {
+ mom_last_t[2] = mom_last_t[1]
+ mom_last_x[2] = mom_last_x[1]
+ mom_last_y[2] = mom_last_y[1]
+ mom_last_t[1] = mom_last_t[0]
+ mom_last_x[1] = mom_last_x[0]
+ mom_last_y[1] = mom_last_y[0]
+ }
+ }
+ }
+
+ function start_momentum() {
+ if (mom_last_t) {
+ let dt = mom_last_t[0] - mom_last_t[2]
+ if (dt > 5) {
+ mom_time = Date.now()
+ mom_vx = (mom_last_x[0] - mom_last_x[2]) / dt
+ mom_vy = (mom_last_y[0] - mom_last_y[2]) / dt
+ if (Math.hypot(mom_vx, mom_vy) < THRESHOLD)
+ mom_vx = mom_vy = 0
+ if (mom_vx || mom_vy)
+ timer = requestAnimationFrame(update_momentum)
+ }
+ }
+ }
+
+ function stop_momentum() {
+ cancelAnimationFrame(timer)
+ timer = 0
+ }
+
+ function update_momentum() {
+ var now = Date.now()
+ var dt = now - mom_time
+ mom_time = now
+
+ transform1.x = transform1.x + mom_vx * dt
+ transform1.y = transform1.y + mom_vy * dt
+ update_transform()
+
+ var decay = Math.pow(0.5, dt / DECELERATION)
+ mom_vx *= decay
+ mom_vy *= decay
+
+ if (Math.hypot(mom_vx, mom_vy) < THRESHOLD)
+ mom_vx = mom_vy = 0
+
+ if (mom_vx || mom_vy)
+ timer = requestAnimationFrame(update_momentum)
+ }
+
+ e_scroll.ontouchstart = function (evt) {
+ anchor_transform(evt.touches)
+ stop_momentum()
+ start_measure(evt.timeStamp)
+ }
+
+ e_scroll.ontouchend = function (evt) {
+ anchor_transform(evt.touches)
+ if (evt.touches.length === 0)
+ start_momentum()
+ }
+
+ e_scroll.ontouchmove = function (evt) {
+ if (evt.touches.length !== last_touch_length)
+ anchor_transform(evt.touches)
+
+ if (evt.touches.length === 1 || evt.touches.length === 2) {
+ let a = evt.touches[0]
+
+ let dx = a.clientX - last_touch_x[a.identifier]
+ let dy = a.clientY - last_touch_y[a.identifier]
+
+ transform1.scale = transform0.scale
+ transform1.x = transform0.x + dx
+ transform1.y = transform0.y + dy
+
+ if (evt.touches.length === 1)
+ move_measure(evt.timeStamp)
+ else
+ abort_measure()
+
+ // zoom
+ if (evt.touches.length === 2) {
+ let b = evt.touches[1]
+
+ let old_x = last_touch_x[a.identifier] - last_touch_x[b.identifier]
+ let old_y = last_touch_y[a.identifier] - last_touch_y[b.identifier]
+ let old = Math.sqrt(old_x * old_x + old_y * old_y)
+
+ let cur_x = a.clientX - b.clientX
+ let cur_y = a.clientY - b.clientY
+ let cur = Math.sqrt(cur_x * cur_x + cur_y * cur_y)
+
+ let scale = clamp_scale(cur / old)
+
+ let cx = a.clientX
+ let cy = a.clientY
+
+ transform1.x -= cx
+ transform1.y -= cy
+
+ transform1.scale *= scale
+ transform1.x *= scale
+ transform1.y *= scale
+
+ transform1.x += cx
+ transform1.y += cy
+ }
+
+ update_transform()
+ }
+ }
+
+ e_scroll.addEventListener(
+ "wheel",
+ function (evt) {
+ if (evt.ctrlKey) {
+ anchor_transform(null)
+
+ let win_w = e_scroll.clientWidth
+ let win_h = e_scroll.clientHeight
+ let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h)
+
+ // one "click" of 120 units -> 10% change
+ let new_scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale + event.wheelDeltaY / 1200))
+
+ // snap to 1 if close
+ console.log("WHEEL ", new_scale, Math.abs(event.wheelDeltaY / 2400))
+ if (Math.abs(1 - new_scale) < Math.abs(event.wheelDeltaY / 2400)) {
+ console.log("SNAP TO 1 ", Math.abs(event.wheelDeltaY / 2400))
+ new_scale = 1
+ }
+
+ transform1.x -= event.clientX
+ transform1.y -= event.clientY
+
+ transform1.x *= new_scale / transform1.scale
+ transform1.y *= new_scale / transform1.scale
+ transform1.scale = new_scale
+
+ transform1.x += event.clientX
+ transform1.y += event.clientY
+
+ update_transform()
+ evt.preventDefault()
+ }
+ },
+ { passive: false }
+ )
+
+ toggle_zoom = toggle_zoom_imp
+})()
diff --git a/public/common/replay.js b/public/common/replay.js
index 0cf6636..c1033f5 100644
--- a/public/common/replay.js
+++ b/public/common/replay.js
@@ -308,7 +308,7 @@ async function load_replay() {
set_hash(replay.length)
on_hash_change()
window.addEventListener("hashchange", on_hash_change)
- document.querySelector("aside").appendChild(replay_panel)
+ document.querySelector("body").appendChild(replay_panel)
} else {
console.log("REPLAY NOT AVAILABLE")
replay_state = body.state
@@ -322,7 +322,7 @@ async function load_replay() {
for (let r of roles)
create_viewpoint_button(viewpoint_panel, r.role, r.role)
create_viewpoint_button(viewpoint_panel, "Observer", "Observer")
- document.querySelector("header").appendChild(viewpoint_panel)
+ document.getElementById("actions").appendChild(viewpoint_panel)
// Adjust replay panel
document.getElementById("replay_step_prev").classList.remove("hide")
diff --git a/public/style.css b/public/style.css
index 4bbd0bd..15c44ab 100644
--- a/public/style.css
+++ b/public/style.css
@@ -143,27 +143,38 @@ body {
header {
display: flex;
- align-items: center;
+ align-items: end;
justify-content: space-between;
- padding-right: 1em;
+ padding-right: 12px;
background-color: var(--color-head);
border-bottom: 2px solid var(--color-accent);
}
header img {
display: block;
- margin: 4px 0 -2px 2px;
+ margin: 0 0 -2px 2px;
}
-header nav {
+nav {
display: flex;
+ align-items: center;
flex-wrap: wrap;
justify-content: end;
+ min-height: 42px;
+ padding: 4px;
+ gap: 8px;
}
-header nav > * {
+nav a {
+ padding: 0 4px;
display: block;
- padding: 0 8px;
+}
+
+@media (pointer: coarse) {
+ nav {
+ padding: 8px;
+ gap: 12px;
+ }
}
article {
@@ -328,8 +339,6 @@ div.body img {
border: var(--thin-border);
}
-/* LIGHT MODE - GAME BOXES */
-
.game_item .game_head { background-color: gainsboro }
.game_item .game_main { background-color: whitesmoke }