diff options
author | Tor Andersson <tor@ccxvii.net> | 2023-10-17 18:26:13 +0200 |
---|---|---|
committer | Tor Andersson <tor@ccxvii.net> | 2023-10-20 22:34:41 +0200 |
commit | 4d0d67cefff209d82fad25ce8a44ad310b986ed0 (patch) | |
tree | 4f434942735ea9658f4d6eb05777dc1ef2524e9b | |
parent | 78a3447cc5766133a8757998d41e4187a1811c20 (diff) | |
download | server-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.md | 45 | ||||
-rw-r--r-- | public/common/columbia.css | 19 | ||||
-rw-r--r-- | public/common/play.css | 225 | ||||
-rw-r--r-- | public/common/play.js | 492 | ||||
-rw-r--r-- | public/common/replay.js | 4 | ||||
-rw-r--r-- | public/style.css | 25 |
6 files changed, 602 insertions, 208 deletions
@@ -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 } |