Pause menu map: smooth zoom and pan with inertia
Replace snappy map zoom and drag behavior with smooth animations. Wheel zoom now eases toward the cursor, keeping the map point under the pointer fixed during scale. Drag pan eases toward the pointer and coasts with inertia on release, with edge clamps and friction damping. Animations cancel each other to prevent fighting. Update tab labels from full names to shorter versions (FALLOUT 4, MULTIPLAYER). Add dev log entry and vanilla settings integration plan.
This commit is contained in:
@@ -10,12 +10,15 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Pause menu map wheel zoom now scales smoothly toward the mouse cursor instead of snapping to the viewport center.
|
||||||
|
- Pause menu map drag-pan eases toward the pointer and coasts with inertia on release.
|
||||||
- Pause menu settings prototype now uses two tabs only: **GAME SETTINGS** (placeholder to open vanilla Fallout 4 settings later) and **MULTIPLAYER SETTINGS** (custom Commonwealth Online options for Profile, Voice Chat, Map & Social, and Network). Removed the full category clone (Gameplay, Display, Audio, Controls, Accessibility, etc.).
|
- Pause menu settings prototype now uses two tabs only: **GAME SETTINGS** (placeholder to open vanilla Fallout 4 settings later) and **MULTIPLAYER SETTINGS** (custom Commonwealth Online options for Profile, Voice Chat, Map & Social, and Network). Removed the full category clone (Gameplay, Display, Audio, Controls, Accessibility, etc.).
|
||||||
- Multiplayer settings are data-driven (`text`, `toggle`, `select`, `slider`, `keybind`) with selected-row descriptions and keyboard navigation (tabs / list focus, value cycling).
|
- Multiplayer settings are data-driven (`text`, `toggle`, `select`, `slider`, `keybind`) with selected-row descriptions and keyboard navigation (tabs / list focus, value cycling).
|
||||||
- `window.CommonwealthOnlineUI` bridge stubs now expose `openGameSettings`, `setMultiplayerSetting`, `onBack`, and `onOpenView`.
|
- `window.CommonwealthOnlineUI` bridge stubs now expose `openGameSettings`, `setMultiplayerSetting`, `onBack`, and `onOpenView`.
|
||||||
- Selected multiplayer setting rows use a solid amber bar with a thin green leading edge instead of a muddy green-to-amber fade.
|
- Selected multiplayer setting rows use a solid amber bar with a thin green leading edge instead of a muddy green-to-amber fade.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Plan for opening vanilla Fallout 4 settings from the custom pause UI (`docs/pause-menu-vanilla-settings-plan.md`): hide pause left column, show settings panel only, restore PrismaUI on back.
|
||||||
- Settings view on the pause menu prototype (`ui/views/CommonwealthOnline/pause-menu/`) with keyboard navigation, controller prompt bar, and F4SE bridge placeholders.
|
- Settings view on the pause menu prototype (`ui/views/CommonwealthOnline/pause-menu/`) with keyboard navigation, controller prompt bar, and F4SE bridge placeholders.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -7,6 +7,43 @@ failed experiments, successful tests, and next steps.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-04 - Pause Menu Map: Cursor-Centered Zoom
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
Map mode wheel zoom now scales smoothly toward the mouse cursor, keeping the map point under the pointer fixed while animating.
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `ui/views/CommonwealthOnline/pause-menu/script.js`
|
||||||
|
- `ui/views/CommonwealthOnline/pause-menu/styles.css`
|
||||||
|
- `changelog.md`
|
||||||
|
- `docs/dev-log.md`
|
||||||
|
|
||||||
|
### Details
|
||||||
|
- Wheel input updates a target zoom; `requestAnimationFrame` eases the current zoom toward it
|
||||||
|
- Each animation frame recomputes pan from the cursor focus point so the anchored map point does not drift
|
||||||
|
- Drag-pan updates a target offset and eases the map toward it; release applies friction-based inertia
|
||||||
|
- Zoom and pan animations cancel each other so they do not fight
|
||||||
|
- CSS no longer transitions `transform` (JS owns pan/zoom motion; filter blur still transitions)
|
||||||
|
- Zooming fully out still recenters via pan clamp at minimum zoom
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Open `ui/views/CommonwealthOnline/pause-menu/index.html` in a browser
|
||||||
|
- Enter MAP mode
|
||||||
|
- Hover over a marker or map feature away from center and scroll the wheel
|
||||||
|
- Confirm zoom eases in/out and that point stays under the cursor
|
||||||
|
- Drag the map and confirm it eases with the pointer, then coasts briefly on release
|
||||||
|
- Confirm a slow release does not coast, and hitting the map edge stops inertia on that axis
|
||||||
|
- Confirm drag interrupts zoom and wheel zoom interrupts pan inertia
|
||||||
|
- Confirm zooming fully out recenters the map
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Controller RS zoom is still prompt-only (no stick input wired yet)
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Wire controller right-stick zoom (screen-center is fine for gamepad)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-04 - Pause Menu Settings: Game vs Multiplayer Tabs
|
## 2026-07-04 - Pause Menu Settings: Game vs Multiplayer Tabs
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Pause Menu: Vanilla Game Settings Integration Plan
|
||||||
|
|
||||||
|
**Status:** Planned (not implemented)
|
||||||
|
**Date:** 2026-07-04
|
||||||
|
**Related prototype:** `ui/views/CommonwealthOnline/pause-menu/`
|
||||||
|
**Related inject pattern:** `plugin/src/F4TMainMenuInject.cpp`
|
||||||
|
|
||||||
|
This document records the agreed approach for opening Fallout 4’s built-in settings from the Commonwealth Online custom pause UI, without showing QUICKSAVE, LOAD, QUIT, and the rest of the vanilla pause list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
From the custom pause menu **SETTINGS → GAME SETTINGS → OPEN FALLOUT 4 SETTINGS**:
|
||||||
|
|
||||||
|
1. Show **only** the vanilla settings panel (right column: Audio, Display, etc.).
|
||||||
|
2. **Hide** the entire left pause column (QUICKSAVE, SAVE, LOAD, INSTALLED, MODS, HELP, SETTINGS, QUIT).
|
||||||
|
3. When the player presses **B / Escape** and backs out of settings, **restore the custom PrismaUI pause menu**.
|
||||||
|
|
||||||
|
Multiplayer options stay in the custom HTML **MULTIPLAYER SETTINGS** tab. This plan is only for the full vanilla game settings UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why not simpler approaches
|
||||||
|
|
||||||
|
### PrismaUI always on top
|
||||||
|
|
||||||
|
PrismaUI_F4 composites HTML in the D3D11 `Present` hook, **after** the full game frame (including all Scaleform menus). While a PrismaUI view is shown, it sits above PauseMenu and any settings UI.
|
||||||
|
|
||||||
|
So you **cannot** truly interleave:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PauseMenu (left list) < custom UI < Settings panel
|
||||||
|
```
|
||||||
|
|
||||||
|
You only get:
|
||||||
|
|
||||||
|
```text
|
||||||
|
all Scaleform < PrismaUI (while the view is visible)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings is not a standalone menu
|
||||||
|
|
||||||
|
In Fallout 4, settings is the **pause menu with SETTINGS selected**: left list + right settings panel in the same StartMenu / PauseMenu UI. Hiding PrismaUI and “opening settings” alone still shows QUICKSAVE, LOAD, QUIT, etc.
|
||||||
|
|
||||||
|
### Render-to-texture inside SWF
|
||||||
|
|
||||||
|
Possible in principle (custom `IMenu`, Ultralight → texture → Scaleform image) so custom UI participates in the menu stack. That is a large research project. Editing MainMenu / PauseMenu ActionScript is fragile (crashes already seen on main menu AS changes), which is why Multiplayer uses list inject + PrismaUI instead.
|
||||||
|
|
||||||
|
**Decision:** Do not use RTT/SWF hosting for this feature. Use runtime GFx + menu lifecycle, same class of work as main-menu inject.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chosen approach
|
||||||
|
|
||||||
|
Treat vanilla settings as a **modal sub-flow** owned by C++:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CustomPause → VanillaSettingsOnly → CustomPause
|
||||||
|
```
|
||||||
|
|
||||||
|
| Phase | What the player sees | PrismaUI | PauseMenu |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Custom pause | CO map / left nav / settings tabs | Shown + focused | Closed, or open but fully covered / unused |
|
||||||
|
| Vanilla settings only | Right-hand settings panel only | Hidden + unfocused | Open; left column hidden; navigated into settings |
|
||||||
|
| Back from settings | Custom pause again | Shown + focused | Closed or reset; left column restored if needed |
|
||||||
|
|
||||||
|
This is the **opposite** of Multiplayer inject: remove / hide pause rows and chrome instead of inserting a row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical background
|
||||||
|
|
||||||
|
### Shared StartMenu UI
|
||||||
|
|
||||||
|
- `RE::PauseMenu` and `RE::MainMenu` both inherit `RE::StartMenuBase`.
|
||||||
|
- Title and pause share the same list pattern the inject already uses:
|
||||||
|
|
||||||
|
- `MainPanel_mc.List_mc`
|
||||||
|
- `MainPanel_mc.List_mc.entryList`
|
||||||
|
|
||||||
|
- Pause mode is detectable via the movie variable `PauseMode` (main-menu inject **skips** inject when `PauseMode` is true).
|
||||||
|
- `StartMenuBase` exposes useful targets, including `mainPanel`, `settingsCategoryList`, and `settingsList`.
|
||||||
|
|
||||||
|
### Existing inject pattern to mirror
|
||||||
|
|
||||||
|
`F4TMainMenuInject.cpp`:
|
||||||
|
|
||||||
|
- Resolves `entryList` on the live GFx movie.
|
||||||
|
- Inserts a row (`text` + logical `index`).
|
||||||
|
- Calls `listWidget.Invoke("InvalidateData")`.
|
||||||
|
- Hooks `InitMainList` so the list is re-applied after the game rebuilds it.
|
||||||
|
- Opens the browser by handling item press / returning to `MAIN_STATE`.
|
||||||
|
|
||||||
|
Pause settings work should follow the same style: **runtime GFx only**, no SWF decompile, scoped to a dedicated flow flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation plan
|
||||||
|
|
||||||
|
### 1. Bridge from HTML
|
||||||
|
|
||||||
|
Prototype already stubs:
|
||||||
|
|
||||||
|
- `window.CommonwealthOnlineUI.openGameSettings`
|
||||||
|
|
||||||
|
Wire this to F4SE (same pattern as server browser interop): JS invoke → plugin enters `VanillaSettingsOnly`.
|
||||||
|
|
||||||
|
### 2. Enter `VanillaSettingsOnly`
|
||||||
|
|
||||||
|
On `openGameSettings`:
|
||||||
|
|
||||||
|
1. Set a plugin flag, e.g. `g_vanillaSettingsFromCustomPause = true`.
|
||||||
|
2. Ensure `PauseMenu` is open (open it if the custom pause flow did not already leave it open for pause state).
|
||||||
|
3. Resolve pause-mode `MainPanel_mc.List_mc.entryList`.
|
||||||
|
4. **Hide the entire left column** via GFx `SetVisible(false)` on `MainPanel_mc` / list (preferred for “only settings visible”). Optionally also strip `entryList` to only SETTINGS as a safety net so nothing in the left list remains activatable if visibility fails.
|
||||||
|
5. **Open settings** programmatically:
|
||||||
|
- Discover SETTINGS logical `index` in pause mode by logging `entryList` while paused (indices differ from title screen; do not reuse `kLoadIndex` / `kMultiplayerIndex`).
|
||||||
|
- Select SETTINGS and invoke the same open path the list uses (mirror `OpenMultiplayerMenu` / item-press handling), **or** drive `currentState` / settings panel if a stable GFx path is found.
|
||||||
|
6. **Hide and unfocus** the PrismaUI pause view so input goes to vanilla settings.
|
||||||
|
|
||||||
|
Player should see only the settings panel (and game blur / pause chrome that is not the left list).
|
||||||
|
|
||||||
|
### 3. Stay in settings
|
||||||
|
|
||||||
|
While `g_vanillaSettingsFromCustomPause`:
|
||||||
|
|
||||||
|
- If `InitMainList` (or equivalent) rebuilds the pause list, **re-hide** the left column (and re-strip if stripping).
|
||||||
|
- Do not run title-screen Multiplayer inject on this menu.
|
||||||
|
- Do not show the custom pause UI.
|
||||||
|
|
||||||
|
### 4. Exit back to custom pause
|
||||||
|
|
||||||
|
Detect leave-settings / back:
|
||||||
|
|
||||||
|
- PauseMenu `currentState` returns from settings toward main pause list, and/or
|
||||||
|
- Settings panel closes, and/or
|
||||||
|
- B / Escape after settings (prefer detecting state, not only the key).
|
||||||
|
|
||||||
|
On exit:
|
||||||
|
|
||||||
|
1. Restore left column visibility (and full `entryList` if stripped).
|
||||||
|
2. Close `PauseMenu` if it was only opened for this flow, **or** hide it immediately so the player never sees QUICKSAVE / LOAD / QUIT.
|
||||||
|
3. Clear `g_vanillaSettingsFromCustomPause`.
|
||||||
|
4. **Show + focus** the PrismaUI pause view (settings tab / game settings pane as appropriate).
|
||||||
|
5. Ensure Escape handling returns to custom UI behavior (JS `keydown` / plugin focus), same idea as the server browser.
|
||||||
|
|
||||||
|
**Important:** Vanilla often backs from settings **to the pause list**, then B again closes pause. For this flow, the first back from settings should go **straight to custom UI**, with no flash of the full pause list. Hide/close PauseMenu as soon as settings is left.
|
||||||
|
|
||||||
|
### 5. Abort / edge cases
|
||||||
|
|
||||||
|
- Player force-closes menus, loads, or quits: clear the flag, restore list visibility, do not leave PauseMenu permanently stripped.
|
||||||
|
- Custom pause closed while settings open: still clear flag and restore vanilla pause to a normal state.
|
||||||
|
- Resolution / safe area: left-column hide is clip-based, so it should not need a transparent “hole” rect (unlike a PrismaUI mask approach).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State machine (summary)
|
||||||
|
|
||||||
|
```text
|
||||||
|
openGameSettings()
|
||||||
|
[CustomPause] ──────────────────────────► [VanillaSettingsOnly]
|
||||||
|
▲ │
|
||||||
|
│ settings back / Escape │
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
restore list, close/hide PauseMenu,
|
||||||
|
Show+Focus PrismaUI pause view
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives considered (not chosen for v1)
|
||||||
|
|
||||||
|
| Approach | Why not for v1 |
|
||||||
|
| --- | --- |
|
||||||
|
| Transparent PrismaUI hole over settings panel | Possible, but alignment and input focus are harder; left list still exists underneath |
|
||||||
|
| Hide PrismaUI only (full pause menu) | Shows QUICKSAVE / LOAD / QUIT — fails the goal |
|
||||||
|
| Custom `IMenu` + Ultralight render-to-texture | Correct for true stack interleaving; too large for this feature |
|
||||||
|
| Patch PauseMenu.swf ActionScript | Crash risk; project already avoided this on main menu |
|
||||||
|
| Reimplement all FO4 settings in HTML | Huge scope; vanilla settings already work |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discovery checklist (before coding)
|
||||||
|
|
||||||
|
Run in-game with F4SE logging while PauseMenu is open:
|
||||||
|
|
||||||
|
1. Confirm `PauseMode == true` on the pause movie.
|
||||||
|
2. Dump `MainPanel_mc.List_mc.entryList` entries (`text`, `index`) and note SETTINGS index.
|
||||||
|
3. Confirm `SetVisible(false)` on the left panel clip hides the whole column without breaking the settings panel.
|
||||||
|
4. Find a reliable way to open settings (item press invoke vs `currentState` / menuObj members).
|
||||||
|
5. Find a reliable “left settings” signal for returning to custom UI.
|
||||||
|
6. Confirm B / Escape order: settings → (must not show list) → custom UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested implementation order
|
||||||
|
|
||||||
|
1. Pause menu PrismaUI view wired like the server browser (show/hide/focus), without vanilla settings yet.
|
||||||
|
2. `openGameSettings` bridge → open PauseMenu, hide PrismaUI (accept full pause list temporarily).
|
||||||
|
3. Hide left column + auto-open settings.
|
||||||
|
4. Exit detection → restore custom pause with no list flash.
|
||||||
|
5. `InitMainList` re-apply while flag is set.
|
||||||
|
6. Polish: abort paths, logging, controller + keyboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files likely involved
|
||||||
|
|
||||||
|
| Area | Path |
|
||||||
|
| --- | --- |
|
||||||
|
| HTML / bridge stub | `ui/views/CommonwealthOnline/pause-menu/script.js` |
|
||||||
|
| Pause PrismaUI (new, similar to browser) | `plugin/src/F4TPrismaUI.cpp` (or dedicated pause module) |
|
||||||
|
| Pause list hide / settings open (new) | e.g. `plugin/src/F4TPauseMenuSettings.cpp` — mirror `F4TMainMenuInject.cpp` |
|
||||||
|
| Types | `RE::PauseMenu`, `RE::StartMenuBase` in CommonLibF4 |
|
||||||
|
| Prototype UI copy | `ui/views/CommonwealthOnline/pause-menu/index.html` (GAME SETTINGS pane) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- From custom pause, player can open full vanilla game settings.
|
||||||
|
- Left pause column never visible during that flow.
|
||||||
|
- No QUICKSAVE / LOAD / QUIT (or other pause actions) reachable during that flow.
|
||||||
|
- B / Escape from settings returns to custom pause, not a bare vanilla pause list.
|
||||||
|
- Normal title-screen Multiplayer inject unchanged.
|
||||||
|
- No permanent PauseMenu SWF edits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual test checklist (when implemented)
|
||||||
|
|
||||||
|
- Server / game running; custom pause opens in-game via PrismaUI.
|
||||||
|
- SETTINGS → GAME SETTINGS → OPEN FALLOUT 4 SETTINGS.
|
||||||
|
- Only settings panel visible; left column absent.
|
||||||
|
- Change a setting (e.g. audio slider); confirm it applies.
|
||||||
|
- B / Escape returns to custom pause (settings tab), no flash of vanilla list.
|
||||||
|
- Open settings again; still correct.
|
||||||
|
- Quit flow / reload; pause menu not permanently broken if opened without custom UI (if vanilla pause remains reachable at all).
|
||||||
|
- Controller and keyboard both work in vanilla settings and after return.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Main menu inject (add row): `plugin/src/F4TMainMenuInject.cpp`
|
||||||
|
- PrismaUI view lifecycle: `ThirdParty/framework-F4-Conversion/docs/view-lifecycle.md`
|
||||||
|
- PrismaUI architecture (Present overlay): `ThirdParty/framework-F4-Conversion/README.md`
|
||||||
|
- Pause menu prototype settings UI: `ui/views/CommonwealthOnline/pause-menu/`
|
||||||
|
- Menu flags / depth (Scaleform stack): `RE::UI_MENU_FLAGS`, `RE::UI_DEPTH_PRIORITY`
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
aria-selected="false"
|
aria-selected="false"
|
||||||
aria-controls="gameSettingsPane"
|
aria-controls="gameSettingsPane"
|
||||||
>
|
>
|
||||||
GAME SETTINGS
|
FALLOUT 4
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
aria-selected="true"
|
aria-selected="true"
|
||||||
aria-controls="multiplayerSettingsPane"
|
aria-controls="multiplayerSettingsPane"
|
||||||
>
|
>
|
||||||
MULTIPLAYER SETTINGS
|
MULTIPLAYER
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1109,13 +1109,32 @@
|
|||||||
if (pauseScreen && mapWorld) {
|
if (pauseScreen && mapWorld) {
|
||||||
var MAX_ZOOM = 4;
|
var MAX_ZOOM = 4;
|
||||||
var ZOOM_STEP = 0.18;
|
var ZOOM_STEP = 0.18;
|
||||||
|
var ZOOM_SMOOTHING = 0.18;
|
||||||
|
var ZOOM_SNAP_EPSILON = 0.001;
|
||||||
|
var PAN_SMOOTHING = 0.28;
|
||||||
|
var PAN_SNAP_EPSILON = 0.15;
|
||||||
|
var PAN_INERTIA_FRICTION = 0.92;
|
||||||
|
var PAN_INERTIA_MIN_SPEED = 0.35;
|
||||||
|
var PAN_VELOCITY_IDLE_MS = 80;
|
||||||
var zoom = 1;
|
var zoom = 1;
|
||||||
|
var targetZoom = 1;
|
||||||
var MIN_ZOOM = 1;
|
var MIN_ZOOM = 1;
|
||||||
var panX = 0;
|
var panX = 0;
|
||||||
var panY = 0;
|
var panY = 0;
|
||||||
|
var targetPanX = 0;
|
||||||
|
var targetPanY = 0;
|
||||||
|
var velocityX = 0;
|
||||||
|
var velocityY = 0;
|
||||||
var isDragging = false;
|
var isDragging = false;
|
||||||
var lastX = 0;
|
var lastX = 0;
|
||||||
var lastY = 0;
|
var lastY = 0;
|
||||||
|
var lastMoveTime = 0;
|
||||||
|
var zoomRafId = 0;
|
||||||
|
var panRafId = 0;
|
||||||
|
var zoomFocusScreenX = 0;
|
||||||
|
var zoomFocusScreenY = 0;
|
||||||
|
var zoomFocusWorldX = 0;
|
||||||
|
var zoomFocusWorldY = 0;
|
||||||
|
|
||||||
var mapImg = new Image();
|
var mapImg = new Image();
|
||||||
var mapNaturalWidth = 1024;
|
var mapNaturalWidth = 1024;
|
||||||
@@ -1151,6 +1170,7 @@
|
|||||||
|
|
||||||
if (zoom < MIN_ZOOM) {
|
if (zoom < MIN_ZOOM) {
|
||||||
zoom = MIN_ZOOM;
|
zoom = MIN_ZOOM;
|
||||||
|
targetZoom = MIN_ZOOM;
|
||||||
panX = 0;
|
panX = 0;
|
||||||
panY = 0;
|
panY = 0;
|
||||||
applyMapTransform();
|
applyMapTransform();
|
||||||
@@ -1166,15 +1186,148 @@
|
|||||||
pauseScreen.style.setProperty("--map-pan-y", panY + "px");
|
pauseScreen.style.setProperty("--map-pan-y", panY + "px");
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampPan() {
|
function getPanLimits() {
|
||||||
var zoomedMapWidth = mapDisplayWidth * zoom;
|
var zoomedMapWidth = mapDisplayWidth * zoom;
|
||||||
var zoomedMapHeight = mapDisplayHeight * zoom;
|
var zoomedMapHeight = mapDisplayHeight * zoom;
|
||||||
var viewportWidth = window.innerWidth;
|
var viewportWidth = window.innerWidth;
|
||||||
var viewportHeight = window.innerHeight;
|
var viewportHeight = window.innerHeight;
|
||||||
var maxPanX = Math.max(0, (zoomedMapWidth - viewportWidth) / 2);
|
return {
|
||||||
var maxPanY = Math.max(0, (zoomedMapHeight - viewportHeight) / 2);
|
maxX: Math.max(0, (zoomedMapWidth - viewportWidth) / 2),
|
||||||
panX = Math.max(-maxPanX, Math.min(maxPanX, panX));
|
maxY: Math.max(0, (zoomedMapHeight - viewportHeight) / 2),
|
||||||
panY = Math.max(-maxPanY, Math.min(maxPanY, panY));
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPanValue(x, y) {
|
||||||
|
var limits = getPanLimits();
|
||||||
|
return {
|
||||||
|
x: Math.max(-limits.maxX, Math.min(limits.maxX, x)),
|
||||||
|
y: Math.max(-limits.maxY, Math.min(limits.maxY, y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPan() {
|
||||||
|
var clamped = clampPanValue(panX, panY);
|
||||||
|
panX = clamped.x;
|
||||||
|
panY = clamped.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTargetPan() {
|
||||||
|
var clamped = clampPanValue(targetPanX, targetPanY);
|
||||||
|
targetPanX = clamped.x;
|
||||||
|
targetPanY = clamped.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopZoomAnimation() {
|
||||||
|
if (zoomRafId !== 0) {
|
||||||
|
cancelAnimationFrame(zoomRafId);
|
||||||
|
zoomRafId = 0;
|
||||||
|
}
|
||||||
|
targetZoom = zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPanAnimation() {
|
||||||
|
if (panRafId !== 0) {
|
||||||
|
cancelAnimationFrame(panRafId);
|
||||||
|
panRafId = 0;
|
||||||
|
}
|
||||||
|
targetPanX = panX;
|
||||||
|
targetPanY = panY;
|
||||||
|
velocityX = 0;
|
||||||
|
velocityY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoomFocusPan() {
|
||||||
|
// Keep the focused map point under the cursor while scale changes
|
||||||
|
// (transform-origin is center; pan is applied after scale).
|
||||||
|
panX = zoomFocusScreenX - zoomFocusWorldX * zoom;
|
||||||
|
panY = zoomFocusScreenY - zoomFocusWorldY * zoom;
|
||||||
|
clampPan();
|
||||||
|
targetPanX = panX;
|
||||||
|
targetPanY = panY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickZoomAnimation() {
|
||||||
|
zoomRafId = 0;
|
||||||
|
|
||||||
|
var zoomDelta = targetZoom - zoom;
|
||||||
|
if (Math.abs(zoomDelta) <= ZOOM_SNAP_EPSILON) {
|
||||||
|
zoom = targetZoom;
|
||||||
|
} else {
|
||||||
|
zoom += zoomDelta * ZOOM_SMOOTHING;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyZoomFocusPan();
|
||||||
|
applyMapTransform();
|
||||||
|
|
||||||
|
if (zoom !== targetZoom) {
|
||||||
|
zoomRafId = requestAnimationFrame(tickZoomAnimation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startZoomAnimation() {
|
||||||
|
stopPanAnimation();
|
||||||
|
if (zoomRafId === 0) {
|
||||||
|
zoomRafId = requestAnimationFrame(tickZoomAnimation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickPanAnimation() {
|
||||||
|
panRafId = 0;
|
||||||
|
var keepGoing = false;
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
var dragDx = targetPanX - panX;
|
||||||
|
var dragDy = targetPanY - panY;
|
||||||
|
if (Math.abs(dragDx) <= PAN_SNAP_EPSILON && Math.abs(dragDy) <= PAN_SNAP_EPSILON) {
|
||||||
|
panX = targetPanX;
|
||||||
|
panY = targetPanY;
|
||||||
|
} else {
|
||||||
|
panX += dragDx * PAN_SMOOTHING;
|
||||||
|
panY += dragDy * PAN_SMOOTHING;
|
||||||
|
keepGoing = true;
|
||||||
|
}
|
||||||
|
clampPan();
|
||||||
|
} else {
|
||||||
|
var nextX = panX + velocityX;
|
||||||
|
var nextY = panY + velocityY;
|
||||||
|
var clamped = clampPanValue(nextX, nextY);
|
||||||
|
panX = clamped.x;
|
||||||
|
panY = clamped.y;
|
||||||
|
targetPanX = panX;
|
||||||
|
targetPanY = panY;
|
||||||
|
|
||||||
|
if (panX !== nextX) {
|
||||||
|
velocityX = 0;
|
||||||
|
}
|
||||||
|
if (panY !== nextY) {
|
||||||
|
velocityY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
velocityX *= PAN_INERTIA_FRICTION;
|
||||||
|
velocityY *= PAN_INERTIA_FRICTION;
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(velocityX) > PAN_INERTIA_MIN_SPEED ||
|
||||||
|
Math.abs(velocityY) > PAN_INERTIA_MIN_SPEED
|
||||||
|
) {
|
||||||
|
keepGoing = true;
|
||||||
|
} else {
|
||||||
|
velocityX = 0;
|
||||||
|
velocityY = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMapTransform();
|
||||||
|
|
||||||
|
if (keepGoing) {
|
||||||
|
panRafId = requestAnimationFrame(tickPanAnimation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPanAnimation() {
|
||||||
|
if (panRafId === 0) {
|
||||||
|
panRafId = requestAnimationFrame(tickPanAnimation);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWorld.addEventListener(
|
mapWorld.addEventListener(
|
||||||
@@ -1184,15 +1337,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
var delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
|
||||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + delta));
|
var nextTarget = Math.max(
|
||||||
if (zoom === MIN_ZOOM) {
|
MIN_ZOOM,
|
||||||
panX = 0;
|
Math.min(MAX_ZOOM, targetZoom + (event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP))
|
||||||
panY = 0;
|
);
|
||||||
} else {
|
if (nextTarget === targetZoom && zoom === targetZoom) {
|
||||||
clampPan();
|
return;
|
||||||
}
|
}
|
||||||
applyMapTransform();
|
|
||||||
|
// Anchor on the map point currently under the cursor.
|
||||||
|
zoomFocusScreenX = event.clientX - window.innerWidth / 2;
|
||||||
|
zoomFocusScreenY = event.clientY - window.innerHeight / 2;
|
||||||
|
zoomFocusWorldX = (zoomFocusScreenX - panX) / zoom;
|
||||||
|
zoomFocusWorldY = (zoomFocusScreenY - panY) / zoom;
|
||||||
|
targetZoom = nextTarget;
|
||||||
|
startZoomAnimation();
|
||||||
},
|
},
|
||||||
{ passive: false }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
@@ -1201,27 +1361,62 @@
|
|||||||
if (viewMode !== "map" || zoom <= MIN_ZOOM) {
|
if (viewMode !== "map" || zoom <= MIN_ZOOM) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
stopZoomAnimation();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
|
targetPanX = panX;
|
||||||
|
targetPanY = panY;
|
||||||
|
velocityX = 0;
|
||||||
|
velocityY = 0;
|
||||||
lastX = event.clientX;
|
lastX = event.clientX;
|
||||||
lastY = event.clientY;
|
lastY = event.clientY;
|
||||||
|
lastMoveTime = performance.now();
|
||||||
pauseScreen.classList.add("is-panning");
|
pauseScreen.classList.add("is-panning");
|
||||||
|
startPanAnimation();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("mousemove", function (event) {
|
window.addEventListener("mousemove", function (event) {
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
panX += event.clientX - lastX;
|
|
||||||
panY += event.clientY - lastY;
|
var dx = event.clientX - lastX;
|
||||||
|
var dy = event.clientY - lastY;
|
||||||
|
var now = performance.now();
|
||||||
|
|
||||||
|
targetPanX += dx;
|
||||||
|
targetPanY += dy;
|
||||||
|
clampTargetPan();
|
||||||
|
|
||||||
|
// Blend recent movement into release velocity for inertia.
|
||||||
|
velocityX = velocityX * 0.55 + dx * 0.45;
|
||||||
|
velocityY = velocityY * 0.55 + dy * 0.45;
|
||||||
|
|
||||||
lastX = event.clientX;
|
lastX = event.clientX;
|
||||||
lastY = event.clientY;
|
lastY = event.clientY;
|
||||||
clampPan();
|
lastMoveTime = now;
|
||||||
applyMapTransform();
|
startPanAnimation();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("mouseup", function () {
|
window.addEventListener("mouseup", function () {
|
||||||
|
if (!isDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
pauseScreen.classList.remove("is-panning");
|
pauseScreen.classList.remove("is-panning");
|
||||||
|
|
||||||
|
// If the pointer stopped before release, do not coast.
|
||||||
|
if (performance.now() - lastMoveTime > PAN_VELOCITY_IDLE_MS) {
|
||||||
|
velocityX = 0;
|
||||||
|
velocityY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold any remaining ease-catchup into the coast.
|
||||||
|
velocityX += (targetPanX - panX) * 0.2;
|
||||||
|
velocityY += (targetPanY - panY) * 0.2;
|
||||||
|
targetPanX = panX;
|
||||||
|
targetPanY = panY;
|
||||||
|
startPanAnimation();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ body {
|
|||||||
transform: translate(var(--map-pan-x, 0px), var(--map-pan-y, 0px))
|
transform: translate(var(--map-pan-x, 0px), var(--map-pan-y, 0px))
|
||||||
scale(var(--map-zoom, 1));
|
scale(var(--map-zoom, 1));
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
transition: transform 160ms ease-out, filter 200ms ease-out;
|
/* Transform is driven by JS (smooth zoom / drag pan); only filter animates. */
|
||||||
|
transition: filter 200ms ease-out;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user