feat: add maps layouts
This commit is contained in:
@ -58,8 +58,10 @@ pub struct Config {
|
|||||||
/// Saved position of the layout window (logical pixels).
|
/// Saved position of the layout window (logical pixels).
|
||||||
pub layout_x: i32,
|
pub layout_x: i32,
|
||||||
pub layout_y: i32,
|
pub layout_y: i32,
|
||||||
/// Size (px) of the main layout image rendered in the viewer.
|
/// Saved size of the layout window (logical pixels). The image scales to fill
|
||||||
pub layout_size: u32,
|
/// it; the user adjusts it via the corner resize grip or the settings fields.
|
||||||
|
pub layout_w: u32,
|
||||||
|
pub layout_h: u32,
|
||||||
|
|
||||||
// --- campaign timer ---
|
// --- campaign timer ---
|
||||||
pub timer_enabled: bool,
|
pub timer_enabled: bool,
|
||||||
@ -107,7 +109,8 @@ impl Default for Config {
|
|||||||
feature_layouts: true,
|
feature_layouts: true,
|
||||||
layout_x: 600,
|
layout_x: 600,
|
||||||
layout_y: 160,
|
layout_y: 160,
|
||||||
layout_size: 360,
|
layout_w: 480,
|
||||||
|
layout_h: 600,
|
||||||
timer_enabled: true,
|
timer_enabled: true,
|
||||||
timer_pause_in_town: true,
|
timer_pause_in_town: true,
|
||||||
timer_afk_enabled: true,
|
timer_afk_enabled: true,
|
||||||
|
|||||||
@ -39,6 +39,10 @@ pub struct AppState {
|
|||||||
// the offset from the window's top-left to the cursor; a background thread
|
// the offset from the window's top-left to the cursor; a background thread
|
||||||
// follows the mouse via xdotool (Tauri/KWin won't reposition this window).
|
// follows the mouse via xdotool (Tauri/KWin won't reposition this window).
|
||||||
pub layout_drag: Mutex<Option<(i32, i32)>>,
|
pub layout_drag: Mutex<Option<(i32, i32)>>,
|
||||||
|
// While the user drags the layout window's corner grip: Some(()) signals a
|
||||||
|
// background thread to follow the mouse and resize the window via set_size
|
||||||
|
// (KWin gives this decorationless window no resize borders of its own).
|
||||||
|
pub layout_resize: Mutex<Option<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -284,12 +288,80 @@ fn toggle_layout(app: AppHandle, state: tauri::State<AppState>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resize the layout window to fit its rendered content (called by the frontend
|
/// Resize the layout window to an explicit size and persist it (called by the
|
||||||
/// whenever the viewer's content size changes).
|
/// settings fields). The grip uses `start_layout_resize` for live dragging.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_layout_size(app: AppHandle, width: u32, height: u32) {
|
fn set_layout_size(app: AppHandle, width: u32, height: u32, state: tauri::State<AppState>) {
|
||||||
|
let (w_px, h_px) = (width.max(MIN_LAYOUT_W), height.max(MIN_LAYOUT_H));
|
||||||
if let Some(w) = app.get_webview_window("layout") {
|
if let Some(w) = app.get_webview_window("layout") {
|
||||||
let _ = w.set_size(LogicalSize::new(width.max(80), height.max(80)));
|
let _ = w.set_size(LogicalSize::new(w_px, h_px));
|
||||||
|
}
|
||||||
|
let mut cfg = state.config.lock().unwrap();
|
||||||
|
cfg.layout_w = w_px;
|
||||||
|
cfg.layout_h = h_px;
|
||||||
|
cfg.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum layout-window size (logical px), shared by the grip and settings.
|
||||||
|
const MIN_LAYOUT_W: u32 = 240;
|
||||||
|
const MIN_LAYOUT_H: u32 = 200;
|
||||||
|
|
||||||
|
/// Begin resizing the layout window by its corner grip. Mirrors the drag
|
||||||
|
/// machinery: a background thread follows the mouse (pointermove is unreliable
|
||||||
|
/// in this WebKitGTK window) and resizes via `set_size` until `end_layout_resize`.
|
||||||
|
#[tauri::command]
|
||||||
|
fn start_layout_resize(app: AppHandle, state: tauri::State<AppState>) {
|
||||||
|
let Some(w) = app.get_webview_window("layout") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some((mx, my)) = crate::poe::mouse_position() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let scale = w.scale_factor().unwrap_or(1.0);
|
||||||
|
let Ok(base) = w.inner_size() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let base = base.to_logical::<i32>(scale);
|
||||||
|
*state.layout_resize.lock().unwrap() = Some(());
|
||||||
|
|
||||||
|
let app = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
if app.state::<AppState>().layout_resize.lock().unwrap().is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Safety net so a missed pointerup can't pin the size to the cursor.
|
||||||
|
if start.elapsed().as_secs() > 30 {
|
||||||
|
*app.state::<AppState>().layout_resize.lock().unwrap() = None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let (Some(w), Some((cx, cy))) =
|
||||||
|
(app.get_webview_window("layout"), crate::poe::mouse_position())
|
||||||
|
{
|
||||||
|
let nw = (base.width + (cx - mx)).max(MIN_LAYOUT_W as i32) as u32;
|
||||||
|
let nh = (base.height + (cy - my)).max(MIN_LAYOUT_H as i32) as u32;
|
||||||
|
let _ = w.set_size(LogicalSize::new(nw, nh));
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(16));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop resizing the layout window and persist its final size.
|
||||||
|
#[tauri::command]
|
||||||
|
fn end_layout_resize(app: AppHandle, state: tauri::State<AppState>) {
|
||||||
|
*state.layout_resize.lock().unwrap() = None;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
|
if let Some(w) = app.get_webview_window("layout") {
|
||||||
|
let scale = w.scale_factor().unwrap_or(1.0);
|
||||||
|
if let Ok(s) = w.inner_size() {
|
||||||
|
let ls = s.to_logical::<u32>(scale);
|
||||||
|
let mut cfg = state.config.lock().unwrap();
|
||||||
|
cfg.layout_w = ls.width.max(MIN_LAYOUT_W);
|
||||||
|
cfg.layout_h = ls.height.max(MIN_LAYOUT_H);
|
||||||
|
cfg.save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -625,6 +697,7 @@ pub fn run() {
|
|||||||
status: Mutex::new(Status::default()),
|
status: Mutex::new(Status::default()),
|
||||||
timer: Mutex::new(live_timer),
|
timer: Mutex::new(live_timer),
|
||||||
layout_drag: Mutex::new(None),
|
layout_drag: Mutex::new(None),
|
||||||
|
layout_resize: Mutex::new(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@ -675,6 +748,8 @@ pub fn run() {
|
|||||||
save_layout_geometry,
|
save_layout_geometry,
|
||||||
start_layout_drag,
|
start_layout_drag,
|
||||||
end_layout_drag,
|
end_layout_drag,
|
||||||
|
start_layout_resize,
|
||||||
|
end_layout_resize,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
@ -736,10 +811,10 @@ pub fn run() {
|
|||||||
// Unlike the overlay it is NOT transparent: it's an opaque panel, and
|
// Unlike the overlay it is NOT transparent: it's an opaque panel, and
|
||||||
// transparent WebKitGTK windows don't reliably repaint here (the view
|
// transparent WebKitGTK windows don't reliably repaint here (the view
|
||||||
// stays blank/ghosted), whereas an opaque window composites fine.
|
// stays blank/ghosted), whereas an opaque window composites fine.
|
||||||
let (lx, ly, lsz) = {
|
let (lx, ly, lw, lh) = {
|
||||||
let state = handle.state::<AppState>();
|
let state = handle.state::<AppState>();
|
||||||
let cfg = state.config.lock().unwrap();
|
let cfg = state.config.lock().unwrap();
|
||||||
(cfg.layout_x, cfg.layout_y, cfg.layout_size)
|
(cfg.layout_x, cfg.layout_y, cfg.layout_w, cfg.layout_h)
|
||||||
};
|
};
|
||||||
let _ = WebviewWindowBuilder::new(
|
let _ = WebviewWindowBuilder::new(
|
||||||
&handle,
|
&handle,
|
||||||
@ -747,7 +822,8 @@ pub fn run() {
|
|||||||
WebviewUrl::App("index.html".into()),
|
WebviewUrl::App("index.html".into()),
|
||||||
)
|
)
|
||||||
.title("Exile UI Layouts")
|
.title("Exile UI Layouts")
|
||||||
.inner_size(lsz as f64 + 24.0, lsz as f64 + 110.0)
|
.inner_size(lw as f64, lh as f64)
|
||||||
|
.min_inner_size(MIN_LAYOUT_W as f64, MIN_LAYOUT_H as f64)
|
||||||
.position(lx as f64, ly as f64)
|
.position(lx as f64, ly as f64)
|
||||||
.decorations(false)
|
.decorations(false)
|
||||||
.always_on_top(true)
|
.always_on_top(true)
|
||||||
|
|||||||
@ -2,26 +2,24 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import {
|
import {
|
||||||
getStatus,
|
getStatus,
|
||||||
getConfig,
|
|
||||||
toggleLayout,
|
toggleLayout,
|
||||||
setLayoutSize,
|
|
||||||
startLayoutDrag,
|
startLayoutDrag,
|
||||||
endLayoutDrag,
|
endLayoutDrag,
|
||||||
|
startLayoutResize,
|
||||||
|
endLayoutResize,
|
||||||
type Status,
|
type Status,
|
||||||
type Config,
|
|
||||||
} from "$lib/api";
|
} from "$lib/api";
|
||||||
import {
|
import {
|
||||||
loadManifest,
|
loadManifest,
|
||||||
childrenOf,
|
childrenOf,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
isDeadEnd,
|
isLegend,
|
||||||
rotationBlocked,
|
rotationBlocked,
|
||||||
type LayoutManifest,
|
type LayoutManifest,
|
||||||
} from "$lib/layouts";
|
} from "$lib/layouts";
|
||||||
|
|
||||||
let manifest = $state<LayoutManifest>({});
|
let manifest = $state<LayoutManifest>({});
|
||||||
let status = $state<Status | null>(null);
|
let status = $state<Status | null>(null);
|
||||||
let config = $state<Config | null>(null);
|
|
||||||
|
|
||||||
// Decision-tree cursor + per-zone view state (reset whenever the zone changes).
|
// Decision-tree cursor + per-zone view state (reset whenever the zone changes).
|
||||||
let path = $state("");
|
let path = $state("");
|
||||||
@ -47,9 +45,29 @@
|
|||||||
const areaName = $derived(status?.area_name || areaId || "—");
|
const areaName = $derived(status?.area_name || areaId || "—");
|
||||||
const paths = $derived(manifest[areaId] ?? []);
|
const paths = $derived(manifest[areaId] ?? []);
|
||||||
const hasZone = $derived(paths.length > 0);
|
const hasZone = $derived(paths.length > 0);
|
||||||
|
// Real layout candidates at the current branch (legends are pulled out below).
|
||||||
const candidates = $derived(
|
const candidates = $derived(
|
||||||
childrenOf(paths, path).filter((p) => !excluded.includes(p)),
|
childrenOf(paths, path).filter((p) => !excluded.includes(p) && !isLegend(p)),
|
||||||
);
|
);
|
||||||
|
// The zone's legend pages ("x", "x_x", …), ordered by depth. A legend at depth
|
||||||
|
// d documents the layout candidates at hierarchy level d (verified: e.g. g1_8
|
||||||
|
// ~x explains the level-1 symbols, ~x_x the level-2 ones).
|
||||||
|
const legendPaths = $derived(
|
||||||
|
paths
|
||||||
|
.filter((p) => isLegend(p))
|
||||||
|
.sort((a, b) => a.split("_").length - b.split("_").length),
|
||||||
|
);
|
||||||
|
// The single legend to show for the current level: the i-th legend at level i,
|
||||||
|
// clamped to the last one (so it carries forward when a level has none of its
|
||||||
|
// own), and the last legend once the layout is identified.
|
||||||
|
const activeLegend = $derived.by(() => {
|
||||||
|
if (legendPaths.length === 0) return null;
|
||||||
|
const pathDepth = path === "" ? 0 : path.split("_").length;
|
||||||
|
const idx = resolved
|
||||||
|
? legendPaths.length - 1
|
||||||
|
: Math.min(pathDepth, legendPaths.length - 1);
|
||||||
|
return legendPaths[idx];
|
||||||
|
});
|
||||||
const resolved = $derived(path !== "" && childrenOf(paths, path).length === 0);
|
const resolved = $derived(path !== "" && childrenOf(paths, path).length === 0);
|
||||||
const blocked = $derived(rotationBlocked(areaId));
|
const blocked = $derived(rotationBlocked(areaId));
|
||||||
const transform = $derived(
|
const transform = $derived(
|
||||||
@ -77,13 +95,6 @@
|
|||||||
flipV = false;
|
flipV = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the OS window sized to the rendered content.
|
|
||||||
let boxW = $state(0);
|
|
||||||
let boxH = $state(0);
|
|
||||||
$effect(() => {
|
|
||||||
if (boxW > 0 && boxH > 0) setLayoutSize(Math.ceil(boxW), Math.ceil(boxH));
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
// The layout window is created hidden, and its `listen` event subscription
|
// The layout window is created hidden, and its `listen` event subscription
|
||||||
@ -92,7 +103,6 @@
|
|||||||
// poll it and only react when the area actually changes (cheap; no flicker).
|
// poll it and only react when the area actually changes (cheap; no flicker).
|
||||||
(async () => {
|
(async () => {
|
||||||
manifest = await loadManifest();
|
manifest = await loadManifest();
|
||||||
config = await getConfig();
|
|
||||||
while (alive) {
|
while (alive) {
|
||||||
const s = await getStatus();
|
const s = await getStatus();
|
||||||
if (!status || s.area_id !== status.area_id) {
|
if (!status || s.area_id !== status.area_id) {
|
||||||
@ -107,8 +117,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const imgSize = $derived(config?.layout_size ?? 360);
|
|
||||||
|
|
||||||
// Drag the window by its title bar. WebKitGTK's `data-tauri-drag-region` and
|
// Drag the window by its title bar. WebKitGTK's `data-tauri-drag-region` and
|
||||||
// Tauri's `setPosition` don't move this window under KWin, so the Rust side
|
// Tauri's `setPosition` don't move this window under KWin, so the Rust side
|
||||||
// follows the mouse with xdotool between start/end (driven by pointerdown/up,
|
// follows the mouse with xdotool between start/end (driven by pointerdown/up,
|
||||||
@ -125,9 +133,26 @@
|
|||||||
window.removeEventListener("blur", endDrag);
|
window.removeEventListener("blur", endDrag);
|
||||||
endLayoutDrag();
|
endLayoutDrag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resize the window via the corner grip. Same approach as the drag: the Rust
|
||||||
|
// side follows the mouse (pointermove is flaky in this WebKitGTK window) and
|
||||||
|
// resizes via set_size; pointerdown/up drive start/end.
|
||||||
|
function startResize(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
startLayoutResize();
|
||||||
|
window.addEventListener("pointerup", endResize, { once: true });
|
||||||
|
window.addEventListener("blur", endResize, { once: true });
|
||||||
|
}
|
||||||
|
function endResize() {
|
||||||
|
window.removeEventListener("pointerup", endResize);
|
||||||
|
window.removeEventListener("blur", endResize);
|
||||||
|
endLayoutResize();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="viewer" bind:clientWidth={boxW} bind:clientHeight={boxH}>
|
<div class="viewer">
|
||||||
<div class="bar" role="toolbar" tabindex="-1" onpointerdown={startDrag}>
|
<div class="bar" role="toolbar" tabindex="-1" onpointerdown={startDrag}>
|
||||||
<span class="title">🗺 {areaName}</span>
|
<span class="title">🗺 {areaName}</span>
|
||||||
<button class="close" title="Fermer (hotkey)" onclick={() => toggleLayout()}>✕</button>
|
<button class="close" title="Fermer (hotkey)" onclick={() => toggleLayout()}>✕</button>
|
||||||
@ -136,8 +161,9 @@
|
|||||||
{#if !hasZone}
|
{#if !hasZone}
|
||||||
<div class="empty">Pas de layout répertorié pour cette zone.</div>
|
<div class="empty">Pas de layout répertorié pour cette zone.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Main image: the deepest committed pick, with the zone's orientation. -->
|
<!-- Main image: the deepest committed pick, with the zone's orientation.
|
||||||
<div class="stage" style="width:{imgSize}px;height:{imgSize}px;">
|
The stage flex-grows to fill the window, so the image scales with it. -->
|
||||||
|
<div class="stage">
|
||||||
{#if path !== ""}
|
{#if path !== ""}
|
||||||
<img src={imageUrl(areaId, path)} alt={path} style="transform:{transform};" />
|
<img src={imageUrl(areaId, path)} alt={path} style="transform:{transform};" />
|
||||||
{:else}
|
{:else}
|
||||||
@ -166,12 +192,22 @@
|
|||||||
<button
|
<button
|
||||||
class="link"
|
class="link"
|
||||||
onclick={() => (path = crumbs.slice(0, i + 1).join("_"))}
|
onclick={() => (path = crumbs.slice(0, i + 1).join("_"))}
|
||||||
>{seg === "x" ? "?" : seg}</button>
|
>{seg}</button>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="back" onclick={back}>◀ retour</button>
|
<button class="back" onclick={back}>◀ retour</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Zone legend for the current level: always visible while navigating (not a
|
||||||
|
layout candidate). Rendered without the orientation transform — it's a
|
||||||
|
symbol key, not a map. -->
|
||||||
|
{#if activeLegend}
|
||||||
|
<div class="label">Légende :</div>
|
||||||
|
<div class="legend">
|
||||||
|
<img class="legend-img" src={imageUrl(areaId, activeLegend)} alt="légende" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Candidate thumbnails at the current branch. -->
|
<!-- Candidate thumbnails at the current branch. -->
|
||||||
{#if candidates.length}
|
{#if candidates.length}
|
||||||
<div class="label">{resolved ? "" : path === "" ? "Layouts possibles :" : "Affine :"}</div>
|
<div class="label">{resolved ? "" : path === "" ? "Layouts possibles :" : "Affine :"}</div>
|
||||||
@ -179,8 +215,7 @@
|
|||||||
{#each candidates as c}
|
{#each candidates as c}
|
||||||
<button
|
<button
|
||||||
class="thumb"
|
class="thumb"
|
||||||
class:dead={isDeadEnd(c)}
|
title={c}
|
||||||
title={isDeadEnd(c) ? "Aucun de ceux-ci / non répertorié" : c}
|
|
||||||
onclick={() => pick(c)}
|
onclick={() => pick(c)}
|
||||||
oncontextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -188,7 +223,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={imageUrl(areaId, c)} alt={c} style="transform:{transform};" />
|
<img src={imageUrl(areaId, c)} alt={c} style="transform:{transform};" />
|
||||||
<span class="cap">{isDeadEnd(c) ? "?" : c.split("_").slice(-1)[0]}</span>
|
<span class="cap">{c.split("_").slice(-1)[0]}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@ -197,16 +232,27 @@
|
|||||||
<div class="label done">Layout identifié.</div>
|
<div class="label done">Layout identifié.</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Corner resize grip (the window has no WM resize borders under KWin). -->
|
||||||
|
<div
|
||||||
|
class="grip"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
title="Redimensionner"
|
||||||
|
onpointerdown={startResize}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.viewer {
|
.viewer {
|
||||||
display: inline-flex;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #0f0c08;
|
background: #0f0c08;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: max-content;
|
|
||||||
}
|
}
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -239,10 +285,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.stage {
|
.stage {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 80px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 8px auto 4px;
|
margin: 8px 8px 4px;
|
||||||
background: #000;
|
background: #000;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -250,6 +298,7 @@
|
|||||||
.stage img {
|
.stage img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
}
|
}
|
||||||
.stage .hint {
|
.stage .hint {
|
||||||
@ -329,12 +378,16 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 460px;
|
/* Without this, items stretch on the cross axis, which overrides the
|
||||||
|
thumbs' aspect-ratio and collapses their height to 0. */
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.thumb {
|
.thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 92px;
|
/* Scale with the window width (the viewer fills the viewport), staying
|
||||||
height: 92px;
|
square via aspect-ratio, clamped so it never gets tiny or huge. */
|
||||||
|
width: clamp(72px, 24vw, 200px);
|
||||||
|
aspect-ratio: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #000;
|
background: #000;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@ -345,9 +398,22 @@
|
|||||||
.thumb:hover {
|
.thumb:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.thumb.dead {
|
.legend {
|
||||||
opacity: 0.6;
|
display: flex;
|
||||||
border-style: dashed;
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.legend-img {
|
||||||
|
/* Symbol key: shown whole (contain, never rotated), scaling with the window. */
|
||||||
|
width: clamp(90px, 28vw, 240px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.thumb img {
|
.thumb img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -371,4 +437,20 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.grip {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 0 55%,
|
||||||
|
var(--border) 55% 70%,
|
||||||
|
transparent 70% 80%,
|
||||||
|
var(--accent) 80% 100%
|
||||||
|
);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
setOverlayLocked,
|
setOverlayLocked,
|
||||||
toggleOverlay,
|
toggleOverlay,
|
||||||
toggleLayout,
|
toggleLayout,
|
||||||
|
setLayoutSize,
|
||||||
saveOverlayGeometry,
|
saveOverlayGeometry,
|
||||||
getTimer,
|
getTimer,
|
||||||
timerStart,
|
timerStart,
|
||||||
@ -194,8 +195,23 @@
|
|||||||
<label>Hotkey « layouts »
|
<label>Hotkey « layouts »
|
||||||
<input type="text" bind:value={config.hotkey_layout} onchange={save} />
|
<input type="text" bind:value={config.hotkey_layout} onchange={save} />
|
||||||
</label>
|
</label>
|
||||||
<label>Taille image (px)
|
<label>Largeur fenêtre (px)
|
||||||
<input type="number" min="160" max="800" bind:value={config.layout_size} onchange={save} />
|
<input
|
||||||
|
type="number"
|
||||||
|
min="240"
|
||||||
|
max="2000"
|
||||||
|
bind:value={config.layout_w}
|
||||||
|
onchange={() => { save(); if (config) setLayoutSize(config.layout_w, config.layout_h); }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>Hauteur fenêtre (px)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="200"
|
||||||
|
max="2000"
|
||||||
|
bind:value={config.layout_h}
|
||||||
|
onchange={() => { save(); if (config) setLayoutSize(config.layout_w, config.layout_h); }}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
|
|||||||
@ -17,7 +17,8 @@ export interface Config {
|
|||||||
feature_layouts: boolean;
|
feature_layouts: boolean;
|
||||||
layout_x: number;
|
layout_x: number;
|
||||||
layout_y: number;
|
layout_y: number;
|
||||||
layout_size: number;
|
layout_w: number;
|
||||||
|
layout_h: number;
|
||||||
timer_enabled: boolean;
|
timer_enabled: boolean;
|
||||||
timer_pause_in_town: boolean;
|
timer_pause_in_town: boolean;
|
||||||
timer_afk_enabled: boolean;
|
timer_afk_enabled: boolean;
|
||||||
@ -107,6 +108,8 @@ export const restoreOverlayPosition = () => invoke("restore_overlay_position");
|
|||||||
export const toggleLayout = () => invoke("toggle_layout");
|
export const toggleLayout = () => invoke("toggle_layout");
|
||||||
export const setLayoutSize = (width: number, height: number) =>
|
export const setLayoutSize = (width: number, height: number) =>
|
||||||
invoke("set_layout_size", { width, height });
|
invoke("set_layout_size", { width, height });
|
||||||
|
export const startLayoutResize = () => invoke("start_layout_resize");
|
||||||
|
export const endLayoutResize = () => invoke("end_layout_resize");
|
||||||
export const saveLayoutGeometry = () => invoke("save_layout_geometry");
|
export const saveLayoutGeometry = () => invoke("save_layout_geometry");
|
||||||
export const startLayoutDrag = () => invoke("start_layout_drag");
|
export const startLayoutDrag = () => invoke("start_layout_drag");
|
||||||
export const endLayoutDrag = () => invoke("end_layout_drag");
|
export const endLayoutDrag = () => invoke("end_layout_drag");
|
||||||
|
|||||||
@ -4,7 +4,8 @@
|
|||||||
// where the path is a decision tree encoded as underscore-separated segments:
|
// where the path is a decision tree encoded as underscore-separated segments:
|
||||||
// "1", "2", "3" = the candidate layouts you first see entering a zone
|
// "1", "2", "3" = the candidate layouts you first see entering a zone
|
||||||
// "3_1", "3_2" = refinements of "3" revealed as you explore deeper
|
// "3_1", "3_2" = refinements of "3" revealed as you explore deeper
|
||||||
// "x", "x_x" = "none of these / not catalogued" dead-ends
|
// "x", "x_x" = the zone's legend pages (key to the map symbols), NOT
|
||||||
|
// decision candidates — they're shown persistently.
|
||||||
// You pick the candidate matching what you see in-game, then keep refining until
|
// You pick the candidate matching what you see in-game, then keep refining until
|
||||||
// no children remain. (On disk the space is stored as "~" for URL-safe serving.)
|
// no children remain. (On disk the space is stored as "~" for URL-safe serving.)
|
||||||
|
|
||||||
@ -56,8 +57,12 @@ export function childrenOf(paths: string[], path: string): string[] {
|
|||||||
.sort(sortPaths);
|
.sort(sortPaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A path is an "x" (none-of-these / not catalogued) marker. */
|
/**
|
||||||
export function isDeadEnd(path: string): boolean {
|
* A path is a legend page rather than a layout candidate. These are named with
|
||||||
|
* a leading "x" segment ("x", "x_x", …) and hold the key to the map symbols, so
|
||||||
|
* the viewer pulls them out of the decision tree and keeps them always visible.
|
||||||
|
*/
|
||||||
|
export function isLegend(path: string): boolean {
|
||||||
return path.split("_")[0] === "x";
|
return path.split("_")[0] === "x";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user