diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 73b0ef6c22..d4b8963fca 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -356,6 +356,9 @@ state**._ A controller does **not** hold pure static data, services, or serializ - **App-level UI** — dialogs and widgets that are opened over the map but say nothing about it: the About dialog (`app-info`). They have a controller's lifecycle but not a controller's subject, so they live here and load with the shell. +- **Shared editor behaviour** — interaction helpers several editors call rather than copy: + dialog helpers, tooltips, the default map events (`viewbox-events`), and map modes such as + `annex-mode`, which the States and Provinces editors both drive with their own merge logic. Widgets like `hierarchy-tree` and `minimap` may move to `components/` if they generalize. @@ -401,6 +404,7 @@ classic needs it. - Generates or simulates world data → `generators/` - Serializes, saves, loads, or exports state → `services/io/` - Manages browser/app lifecycle, a platform asset, or app preferences → `services/` +- Talks to the project's help gateway on the user's behalf → `services/help/` - A constant list or template, no behavior → `data/` - A helper that reads no ambient state and has ≥2 consumers → `utils/` - A shared type / interface → `types/` @@ -533,6 +537,10 @@ Static content: lookup tables, templates, tuning constants, reference lists. - **IO is a service.** Save/load/export live in `src/services/io/`. Like controllers, each service/io module exports a single named object (`Save`, `Load`, `ExportMap`, …) reached through the `Services` registry (`Services.Save.saveMap(...)`). +- **The help gateway client is a service.** `src/services/help/` (api, auth, conversation) is the + only code that talks to ask.azgaarsfmg.com; `controllers/help-assistant.ts` is the UI over it. The + client keeps the Discord sign-in token in `localStorage` and only the server-issued conversation id + in `sessionStorage`, never conversation content. ## Lazy module registry diff --git a/docs/architecture/data_model.md b/docs/architecture/data_model.md index c89d45ee8f..6d8174ade3 100644 --- a/docs/architecture/data_model.md +++ b/docs/architecture/data_model.md @@ -330,7 +330,9 @@ unordered `AddedLabel[]`: At runtime, Label Group styles are indexed in `style.labels.groups`, keyed by group id. Current `.map` files serialize the complete global `style` object at data index 48. Pre-1.140 migration reconstructs it from the -legacy SVG group attributes. All label types can share a group without changing their rendering primitive: +legacy SVG group attributes; pre-1.140 zoom auto-visibility wrote `display: none` into the group's inline style, +which the migration strips, and an auto-update pass (1.151.2) strips it from styles records saved by +1.150-1.151.1, so a stored group style never carries `display`. All label types can share a group without changing their rendering primitive: a label with `pathPoints` is rendered as a ``, and any other label as a positioned ``, which the Label Editor lets the user switch for any label regardless of its type. The fallback groups are `states`, `provinces`, the configured default Burg group, and `added` respectively. @@ -341,6 +343,10 @@ Ordered Label Group policy is stored in `options.labels`: - `showAll`: `boolean` - temporary override for per-group active state, zoom bounds, and layer dependencies - `groups`: `LabelGroupOptions[]` - ordered group definitions +`options.labels` and the Burg group registry are seeded from `localStorage` for every new map, so both are +validated on read (`Labels.parseStoredOptions`, `Burgs.parseStoredGroups`): a stored value that would leave +nothing to draw, such as an empty `groups` list, falls back to the defaults instead of being reused. + Each `LabelGroupOptions` contains: - `name`: `string` - globally unique logical group id diff --git a/docs/wiki/Changelog.md b/docs/wiki/Changelog.md index 15ea3ae6b0..cafd777893 100644 --- a/docs/wiki/Changelog.md +++ b/docs/wiki/Changelog.md @@ -12,16 +12,14 @@ Check out [Dev board](https://github.com/users/Azgaar/projects/3/views/1?sumFiel Current version of the Fantasy Map Generator is the latest `master` branch. You can download it here: https://github.com/Azgaar/Fantasy-Map-Generator/archive/refs/heads/master.zip. Also see [the wiki](https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Q&A#can-i-use-the-generator-offline). -# Releases - -**1.151.2 - 2026-09-05**: - +- Help assistant: ask questions about the Generator in the app, answered from the wiki and Discord knowledge by _[barrulus](https://github.com/barrulus)_ [1.151.2] +- Annex states and provinces by clicking on the map by _[barrulus](https://github.com/barrulus)_ [1.151.2] +- Option to redraw labels, icons and relief only after a zoom by _[barrulus](https://github.com/barrulus)_ [1.151.2] - Fix burg label tiers hidden at every zoom on maps migrated from pre-1.140 versions by _[barrulus](https://github.com/barrulus)_ [1.151.2] - -**1.151.1 - 2026-09-03**: - - Brushes stamp by distance travelled: smooth, gap-free painting at any refresh rate by _[barrulus](https://github.com/barrulus)_ [1.151.1] +# Releases + **[1.150.0](https://github.com/Azgaar/Fantasy-Map-Generator/archive/refs/tags/v1.150.0.zip) - 2026-09-05**: - Journey Editor and new Journeys layer [1.150.0] diff --git a/docs/wiki/Dependencies.md b/docs/wiki/Dependencies.md index afa91d1a7f..230ceecbcc 100644 --- a/docs/wiki/Dependencies.md +++ b/docs/wiki/Dependencies.md @@ -35,4 +35,4 @@ Loaded on demand: * [JSZip](https://github.com/Stuk/jszip) by Stuart Knightley, David Duponchel, Franz Buchinger and António Afonso — tile export * [Dropbox SDK](https://github.com/dropbox/dropbox-sdk-js) — saving to and loading from Dropbox * [TinyMCE](https://www.tiny.cloud) — the rich text editor in the Notes editor -* [OpenWidget](https://openwidget.com) — the optional in-app assistant +* Help gateway at [ask.azgaarsfmg.com](https://ask.azgaarsfmg.com) — answers the in-app help assistant; nothing is bundled, the app only calls it when a question is asked diff --git a/docs/wiki/Knowledge Base.md b/docs/wiki/Knowledge Base.md index 25bcc395c8..1a38b30d4f 100644 --- a/docs/wiki/Knowledge Base.md +++ b/docs/wiki/Knowledge Base.md @@ -118,7 +118,7 @@ Please try to reproduce the issue on your own. If it's reproducible, please log ### The map performance is poor, how can I improve it? -Toggle off unnecessary layers. Be mindful of the Relief Icons layer in particular – it’s the most resource-demanding one. Open the Generator in a separate browser window, make it much smaller (about 900 x 560 pixels) and re-generate the map. Then, zoom in to see the map in detail. It will reduce the rending area and drastically improve the performance. When generating maps, set Points number to 10K. Points (cells) number highly affects performance. Toggle off map and element filters. Close all irrelevant browser tabs and applications. Use a leading edge browser (fresh versions on Chrome or Edge). Firefox is reported to be slower +Toggle off unnecessary layers. Be mindful of the Relief Icons layer in particular – it’s the most resource-demanding one. Open the Generator in a separate browser window, make it much smaller (about 900 x 560 pixels) and re-generate the map. Then, zoom in to see the map in detail. It will reduce the rending area and drastically improve the performance. When generating maps, set Points number to 10K. Points (cells) number highly affects performance. Toggle off map and element filters. Close all irrelevant browser tabs and applications. Use a leading edge browser (fresh versions on Chrome or Edge). Firefox is reported to be slower. Set 'Redraw on zoom' in the Options tab to 'After zoom' to redraw labels, icons and relief once per gesture instead of on every frame ### Who owns the maps created? @@ -338,7 +338,7 @@ Go to Tools -> Units and change the '1 map pixel' distance scale. To change how ### How to merge provinces? -Open the Provinces Editor, filter the list by the state the provinces belong to and click on the 'Merge several provinces into one' button at the dialog bottom. Provinces of different states cannot be merged, reassign them in the States Editor first. Alternatively remove a province and repaint its territory to another one with the Paint brush +Open the Provinces Editor and click the 'Annex provinces' button (crown icon) at the dialog bottom. Click the province that absorbs the others on the map, then click the provinces to annex; hold Shift to keep annexing several. A confirmation lists what will be merged, so a mis-click can be cancelled. To pick from a list instead, filter by the state the provinces belong to and use the 'Merge several provinces into one' button. Provinces of different states cannot be merged, reassign them in the States Editor first. Alternatively remove a province and repaint its territory to another one with the Paint brush ### Can you colour in the relief icons? @@ -530,12 +530,20 @@ Not really. The size mostly depends on the points number and cannot be changed f ### How to clean this chat dialog? How can I remove the chat history? -You can open the website cdn.openwidget.com, then Open the Dev Console (F12) and type `localStorage.clear();` there. It will remove the chat history +The assistant remembers the conversation for the current browser tab only, and the service forgets it two hours after the last question. Close the tab or open the Generator in a new one to start fresh; the assistant marks the boundary with a 'new conversation' line ### How can I toggle you off? How can I hide the Assistant? To hide the Assistant, go to Options -> Generator settings and set `Azgaar assistant` to Hide +### The assistant says 'No questions left today'. What now? + +Questions are budgeted per day to keep the shared service affordable. Anonymous use gets a small allowance; click 'Sign in with Discord for more' in the assistant panel for a larger one. The wiki and the Discord server hold the same knowledge the assistant answers from + +### What does the assistant send, and where? + +Only the question you type and a conversation id go to the project's help gateway at ask.azgaarsfmg.com; nothing from your map or browser is sent. Answers are drawn from the wiki and Discord knowledge. Signing in with Discord stores a token in this browser, and 'Sign out' removes it + ### How to make a river? There are two ways to create a new river. Open the Rivers Overview (Tools -> Rivers), click on the Add River button and then click on a map cell to spawn a river from it, the river will be created automatically. Another way is to draw a river manually with the 'Create a new river selecting river cells' button. The same automatic placement is available as Tools -> Add -> River (Shift + 4) @@ -550,7 +558,7 @@ You can create a new Regiment using the Regiments Overview. In can open if you c ### How do I merge countries? -To merge states (countries) open the States Editor from Tools and click on the "Merge several states into one" button at the dialog bottom. Then select states you want to merge +To merge states (countries) open the States Editor from Tools and click the "Annex states" button (crown icon) at the dialog bottom. Click the state that annexes the others on the map, then click the states to annex; hold Shift to keep annexing several. A confirmation lists what will be removed, so a mis-click can be cancelled. To pick from a list instead, use the "Merge several states into one" button next to it and tick the states to merge ### How to start a war? diff --git a/docs/wiki/Q&A.md b/docs/wiki/Q&A.md index 8125f2fef5..11584de302 100644 --- a/docs/wiki/Q&A.md +++ b/docs/wiki/Q&A.md @@ -11,6 +11,7 @@ The performance mainly depends on the number of visible elements and visible map * Toggle off map and element filters. * Close all irrelevant browser tabs and applications. * Use a leading edge browser (fresh versions on Chrome or Edge). Firefox is reported to be slower. +* Set _Redraw on zoom_ in the Options tab to _After zoom_: labels, icons and relief are redrawn once per gesture instead of on every frame. ### Who owns the maps created? You. The Generator is licensed under [MIT license](https://github.com/Azgaar/Fantasy-Map-Generator/blob/master/LICENSE) and derivative works such as maps are free of charge. You can sell them or make them available for free. diff --git a/docs/wiki/Quick-Start-Tutorial.md b/docs/wiki/Quick-Start-Tutorial.md index 47009359c3..c3c19d393b 100644 --- a/docs/wiki/Quick-Start-Tutorial.md +++ b/docs/wiki/Quick-Start-Tutorial.md @@ -83,11 +83,12 @@ Generator settings: * _Transparency_: opacity of the control panes. * _Autosave interval_: number of minutes the map should be auto-saved to browser memory. Set to `0` to disable the autosave. * _Onload behavior_: define what should be done when Generator is opened: a new map generated or a previously saved map auto-opened. -* _Azgaar assistant_: show or hide the in-app chat assistant. +* _Azgaar assistant_: show or hide the help bubble in the bottom right corner, which answers questions about using the Generator. * _Speaker voice_: select the voice used to speak burg and other names. Voice synthesis is provided by the browser. * _Emblem shape_: defines shield shape used during emblems generation. * _Zoom extent_: minimal and maximal zoom levels. Click on the button on the right to restore the default values. * _Rendering_: set map rendering quality. Best quality can reduce the map performance. +* _Redraw on zoom_: when labels, icons and relief are redrawn during a zoom or pan. _After zoom_ redraws once per gesture, which is faster on big maps but makes new content appear all at once. * _Language_: load Google Translate and select a language to translate the interface. Automatic translation can break some functionality — use the reset icon or refresh the page if it does. There is also the _Restore default options_ button. It cancels all user changes and refreshes the page. diff --git a/docs/wiki/User-Interface.md b/docs/wiki/User-Interface.md index 0344b124e0..e83d4e919e 100644 --- a/docs/wiki/User-Interface.md +++ b/docs/wiki/User-Interface.md @@ -349,6 +349,8 @@ Slider that determines the width of the stroke. The lower the value, the thinner the line will be, to the point of non-existence, and conversely, the higher the value, the thicker the line will be. +For label groups the slider range follows the group's font size: stroke width runs up to half of it and letter spacing from a tenth below zero to half above, so river and state labels both get a usable range. The number input still accepts any value. + ### Common style controls (Font related) #### Font size @@ -512,11 +514,12 @@ Below the map settings, the Options tab holds settings that apply immediately an * Theme color and Transparency: the color and opacity of the dialogs. * Autosave interval: how often the map is saved to browser storage. * Onload behavior: generate a new map or load the last saved one when the page opens. -* Azgaar assistant: show or hide the in-app chat assistant. +* Azgaar assistant: show or hide the help bubble in the bottom right corner, which answers questions about the Generator from the wiki and Discord knowledge. * Speaker voice: the voice used by the speaker buttons next to names. * Emblem shape: the default coat of arms shield shape. * Zoom extent: minimal and maximal zoom levels, `[1, 20]` by default. * Rendering: SVG shape-rendering mode, trading quality for speed. +* Redraw on zoom: when labels, icons and relief are redrawn during a zoom or pan. _After zoom_ redraws once per gesture: faster on big maps, but new content appears all at once. * Language: the interface language. The **Configure World** button opens a separate dialog where you set the map size relative to the world, its latitude and longitude shift on the globe, the temperature at the equator and both poles, and the global precipitation. The **Restore default options** button resets everything and reloads the page. diff --git a/electron/main.ts b/electron/main.ts index 819d127857..54613c71ce 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -89,16 +89,13 @@ protocol.registerSchemesAsPrivileged([ /** * A .map file is shared like a document, and the app builds markup out of what is inside it, so the one - * directive that matters is `script-src`: no origin but the build itself may supply code, save for the - * Assistant widget the user opts into. The rest stays permissive, because maps embed data/blob images and - * fonts and the AI providers are fetched over https. `unsafe-eval` is required by the goods distribution - * formulas, which compile to `new Function` + * directive that matters is `script-src`: no external origin may supply code. The rest stays permissive, + * because maps embed data/blob images and fonts and the AI providers are fetched over https. `unsafe-eval` + * is required by the goods distribution formulas, which compile to `new Function` */ -const ASSISTANT_ORIGINS = "https://*.openwidget.com"; - const CSP = [ "default-src 'self' data: blob:", - `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${ASSISTANT_ORIGINS}`, + `script-src 'self' 'unsafe-inline' 'unsafe-eval'`, "style-src 'self' 'unsafe-inline' https:", "font-src 'self' data: https:", "img-src 'self' data: blob: https:", diff --git a/public/index.css b/public/index.css index 1beb58407b..390dcb8d1e 100644 --- a/public/index.css +++ b/public/index.css @@ -2589,17 +2589,6 @@ svg.button { } } -#chat-widget-container { - user-select: none; - z-index: 99 !important; -} - -#chat-widget-minimized { - animation: fadeIn 1s ease-in; - transform: scale(0.65); - opacity: var(--bg-opacity); -} - @keyframes fadeIn { from { opacity: 0; @@ -2743,3 +2732,118 @@ body.tour-free-roam * { gap: 0.5em; margin: 0.8em 0 0.4em 0; } + +#helpAssistantBubble { + position: fixed; + right: 16px; + bottom: 64px; /* clear of the scale bar in the corner */ + width: 44px; + height: 44px; + border-radius: 50%; + background: #35424d; + color: #fff; + font-size: 1.4em; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 6px rgb(0 0 0 / 35%); + z-index: 99; +} + +/* the dialog is fixed-width; undo the global .dialog > div { width: max-content } so + children wrap instead of overflowing */ +#helpAssistant > div { + width: auto; +} + +#helpAssistant .helpAssistantLinks { + display: flex; + gap: 1.2em; + justify-content: center; + margin-top: 0.6em; + padding-top: 0.5em; + border-top: 1px solid rgb(0 0 0 / 12%); + font-size: 0.92em; +} + +#helpAssistant .helpAssistantUnlisted { + max-width: 20em; +} + +#helpAssistant .helpAssistantLog { + max-height: 40vh; + min-height: 8em; + overflow-y: auto; + margin-bottom: 0.5em; +} + +#helpAssistant .helpAssistantDivider { + text-align: center; + opacity: 0.55; + font-size: 0.85em; + margin: 0.5em 0; +} + +#helpAssistant .helpAssistantNewChat { + display: block; + text-align: right; + font-size: 0.85em; + margin-bottom: 0.2em; +} + +#helpAssistant .helpAssistantAsked { + font-style: italic; + opacity: 0.8; +} + +#helpAssistant .helpAssistantFeedback { + display: flex; + gap: 0.3em; + justify-content: flex-end; + margin-top: 0.2em; +} + +#helpAssistant .helpAssistantFeedback button { + background: none; + border: none; + cursor: pointer; + opacity: 0.4; + font-size: 1em; + padding: 0 0.15em; +} + +#helpAssistant .helpAssistantFeedback button:hover { + opacity: 0.8; +} + +#helpAssistant .helpAssistantFeedback button.selected { + opacity: 1; +} + +#helpAssistant .helpAssistantFooter { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.4em 0.8em; + width: 100%; + margin-top: 0.3em; +} + +#helpAssistant .helpAssistantFooter #helpAssistantAuth { + flex: 1; + text-align: right; + font-size: 0.92em; + white-space: nowrap; +} + +#helpAssistant .helpAssistantDisclosure { + font-size: 0.85em; + opacity: 0.7; + margin-top: 0.5em; +} + +#helpAssistant textarea { + width: 100%; + box-sizing: border-box; +} diff --git a/public/libs/openwidget.min.js b/public/libs/openwidget.min.js deleted file mode 100644 index 89583d8ffb..0000000000 --- a/public/libs/openwidget.min.js +++ /dev/null @@ -1 +0,0 @@ -window.__ow=window.__ow||{},window.__ow.organizationId="7bb02e70-bcef-4861-a4e6-d259b0d10e24",window.__ow.integration_name="manual_settings",window.__ow.product_name="openwidget",function(n,e,t){function o(n){return c._h?c._h.apply(null,n):c._q.push(n)}var c={_q:[],_h:null,_v:"2.0",on:function(){o(["on",t.call(arguments)])},once:function(){o(["once",t.call(arguments)])},off:function(){o(["off",t.call(arguments)])},get:function(){if(!c._h)throw Error("[OpenWidget] You can't use getters before load.");return o(["get",t.call(arguments)])},call:function(){o(["call",t.call(arguments)])},init:function(){var n=e.createElement("script");n.async=!0,n.type="text/javascript",n.src="https://cdn.openwidget.com/openwidget.js",e.head.appendChild(n)}};n.__ow.asyncInit||c.init(),n.OpenWidget=n.OpenWidget||c}(window,document,[].slice); \ No newline at end of file diff --git a/public/main.js b/public/main.js index 1a92a274fb..02c4f1cec8 100644 --- a/public/main.js +++ b/public/main.js @@ -44,6 +44,14 @@ bindElementGlobals(); d3.select("#scaleBar") .on("mousemove", () => tip("Click to open Units Editor")) .on("click", () => window.Controllers.UnitsEditor.open()); +document.getElementById("helpAssistantBubble")?.addEventListener("click", () => window.Controllers.HelpAssistant.open()); +document.getElementById("helpAssistantBubble")?.addEventListener("mouseover", showDataTip); +document.getElementById("helpAssistantBubble")?.addEventListener("keydown", event => { + if (event.key === "Enter" || event.key === " ") { + if (event.key === " ") event.preventDefault(); + window.Controllers.HelpAssistant.open(); + } +}); d3.select("#legend") .on("mousemove", () => tip("Drag to change the position. Click to hide the legend")) .on("click", () => clearLegend()); @@ -129,6 +137,36 @@ d3.select("#oceanLayers") .attr("height", graphHeight); document.addEventListener("DOMContentLoaded", async () => { + // OAuth callback from the help gateway: stash the fragment token and scrub the URL. + // Storage key must match TOKEN_STORAGE in src/services/help/auth.ts. The token is taken + // verbatim after "#token=" (opaque token assumed; revisit if the gateway ever appends more + // fragment params). + if (location.hash.startsWith("#token=")) { + // Token-fixation guard: only accept the fragment token if THIS client initiated sign-in + // (flag set in signIn(), src/services/help/api.ts) — otherwise a third party could plant + // #token= in a link and silently sign the victim in as them. + let signInPending = false; + try { + signInPending = sessionStorage.getItem("fmg-help-signin-pending") === "1"; + } catch { + // storage unavailable — treat as not pending, i.e. do not accept the token + } + try { + sessionStorage.removeItem("fmg-help-signin-pending"); + } catch { + // nothing to clear + } + if (signInPending) { + try { + localStorage.setItem("fmg-help-token", location.hash.slice("#token=".length)); + } catch { + // storage unavailable — the user simply stays signed out + } + } + // Always scrub the fragment, accepted or not — an unexpected token must not linger in the URL. + history.replaceState(null, "", location.pathname + location.search); + } + // binds the zoom behaviour and its handlers (see src/components/viewbox-events.ts), so it has to // run before checkLoadParameters - deep links (MFCG, a stored view position) zoom the map on load applyDefaultViewboxEvents(); @@ -282,31 +320,13 @@ function focusOn() { } } -let isAssistantLoaded = false; function toggleAssistant() { if (window.electron) return; + const bubble = document.getElementById("helpAssistantBubble"); + if (!bubble) return; const showAssistant = document.getElementById("azgaarAssistant")?.value === "show"; - if (showAssistant) { - if (isAssistantLoaded) { - const assistantContainer = document.getElementById("chat-widget-container"); - if (assistantContainer) assistantContainer.style.display = "block"; - } else { - import("./libs/openwidget.min.js").then(() => { - isAssistantLoaded = true; - setTimeout(() => { - const bubble = document.getElementById("chat-widget-minimized"); - if (bubble) { - bubble.dataset.tip = "Click to open the Assistant"; - bubble.addEventListener("mouseover", showDataTip); - } - }, 5000); - }); - } - } else if (isAssistantLoaded) { - const assistantContainer = document.getElementById("chat-widget-container"); - if (assistantContainer) assistantContainer.style.display = "none"; - } + bubble.style.display = showAssistant ? "flex" : "none"; } function initTourPromptButton() { diff --git a/public/modules/ui/style.js b/public/modules/ui/style.js index 51ad93305b..faae86c716 100644 --- a/public/modules/ui/style.js +++ b/public/modules/ui/style.js @@ -84,6 +84,27 @@ function getColor(value, scheme = getColorScheme("bright")) { // Toggle style sections on element select styleElementSelect.addEventListener("change", selectStyleElement); +// label groups differ ~10x in font size, so the absolute sliders get a drag range fitted to the group; +// values are stored unscaled and a stored value beyond the fitted range keeps the range wide enough +const defaultRanges = { + strokeMax: styleStrokeWidthInput.getAttribute("max"), + spacingMin: styleLetterSpacingInput.getAttribute("min"), + spacingMax: styleLetterSpacingInput.getAttribute("max") +}; + +function fitLabelRanges(fontSize, attrs) { + const spacing = +attrs["letter-spacing"] || 0; + styleStrokeWidthInput.setAttribute("max", Math.max(rn(fontSize / 2, 2), +attrs["stroke-width"] || 0)); + styleLetterSpacingInput.setAttribute("min", Math.min(-rn(fontSize / 10, 2), spacing)); + styleLetterSpacingInput.setAttribute("max", Math.max(rn(fontSize / 2, 2), spacing)); +} + +function resetLabelRanges() { + styleStrokeWidthInput.setAttribute("max", defaultRanges.strokeMax); + styleLetterSpacingInput.setAttribute("min", defaultRanges.spacingMin); + styleLetterSpacingInput.setAttribute("max", defaultRanges.spacingMax); +} + // groups the editor addresses by name; everything else is styled as a whole const GROUPED_STYLE_ELEMENTS = ["anchors", "borders", "burgIcons", "coastline", "lakes", "labels", "routes", "terrs"]; @@ -95,6 +116,7 @@ function selectStyleElement() { const el = d3.select("#" + styleElement); styleElements.querySelectorAll("tbody").forEach(e => (e.style.display = "none")); // hide all sections + resetLabelRanges(); // show alert line if layer is not visible const isLayerOff = styleElement !== "ocean" && (el.style("display") === "none" || !el.selectAll("*").size()); @@ -274,13 +296,15 @@ function selectStyleElement() { styleSize.style.display = "block"; styleFillInput.value = styleFillOutput.value = attrs.fill || "#3e3e4b"; styleStrokeInput.value = styleStrokeOutput.value = attrs.stroke || "#3a3a3a"; + const fontSize = parseFloat(attrs["font-size"]) || 18; + fitLabelRanges(fontSize, attrs); styleStrokeWidthInput.value = attrs["stroke-width"] ?? 0; styleLetterSpacingInput.value = attrs["letter-spacing"] ?? 0; styleShadowInput.value = getTextShadow(attrs.style); styleFont.style.display = "block"; styleSelectFont.value = attrs["font-family"]; - styleFontSize.value = parseFloat(attrs["font-size"]) || 18; + styleFontSize.value = fontSize; styleFontShift.style.display = "block"; const { dx, dy } = getLabelShift(attrs.style); @@ -973,6 +997,7 @@ function changeFontSize(el, size) { if (styleElementSelect.value === "labels") { el.attr("font-size", `${size}%`).attr("data-size", null); if (groupStyle) groupStyle.attrs["font-size"] = `${size}%`; + fitLabelRanges(size, groupStyle?.attrs || {}); return; } diff --git a/src/components/annex-mode.ts b/src/components/annex-mode.ts new file mode 100644 index 0000000000..c808a7a2b6 --- /dev/null +++ b/src/components/annex-mode.ts @@ -0,0 +1,131 @@ +// Map mode shared by the states and provinces editors: click the annexing entity, then the ones it absorbs +import { select } from "d3"; +import { ensureEl, getPointer } from "@/utils"; +import { clearMainTip, tip } from "./tooltips"; +import { applyDefaultViewboxEvents } from "./viewbox-events"; + +interface AnnexModeOptions { + buttonId: string; + bodySectionId: string; + noun: string; + ownerOf: (cellId: number) => number; + colorOf: (id: number) => string; + nameOf: (id: number) => string; + rejectReason?: (parentId: number, id: number) => string | undefined; + commit: (parentId: number, annexed: number[]) => void; +} + +// any non-zero customization keeps the other editors, saving and regeneration off while annexing +const ANNEX_MODE = 17; + +export function createAnnexMode(options: AnnexModeOptions) { + const { buttonId, bodySectionId, noun, ownerOf, colorOf, nameOf, rejectReason, commit } = options; + let active = false; + let parent = 0; + const staged = new Set(); + + const preview = () => select("#debug").select("g.annex-preview"); + + function toggle(): void { + if (active) finish(); + else enter(); + } + + function enter(): void { + active = true; + customization = ANNEX_MODE; + ensureEl(buttonId).classList.add("pressed"); + select("#debug").append("g").attr("class", "annex-preview"); + tip(`Click the ${noun} that annexes, then the ${noun}s it absorbs. Hold Shift to keep annexing`, true); + select("#viewbox").style("cursor", "crosshair").on("click", onClick); + setRowsInert(true); + } + + function onClick(this: SVGElement, event: MouseEvent): void { + const [x, y] = getPointer(event, this); + const cell = Pack.findCell(x, y); + if (cell === undefined || pack.cells.h[cell] < 20) { + tip(`Click on a land cell to pick a ${noun}`, false, "error"); + return; + } + + const id = ownerOf(cell); + if (!id) { + tip(`There is no ${noun} here`, false, "error"); + return; + } + + if (!parent) { + parent = id; + drawEntity(id, "annex-parent", 0.3); + tip(`Annexing into ${nameOf(id)}. Click the ${noun}s to annex. Hold Shift to keep annexing`, true); + return; + } + + if (id === parent) { + tip(`${nameOf(id)} is the annexing ${noun}`, false, "error"); + return; + } + + const reason = rejectReason?.(parent, id); + if (reason) { + tip(reason, false, "error"); + return; + } + + if (staged.has(id)) { + staged.delete(id); + preview().select(`g[data-id='${id}']`).remove(); + } else { + staged.add(id); + drawEntity(id, "annex-child", 0.7); + } + + if (!event.shiftKey) finish(); + } + + function drawEntity(id: number, className: string, opacity: number): void { + const color = colorOf(parent); + const group = preview().append("g").attr("data-id", id).attr("class", className).attr("opacity", opacity); + const { h } = pack.cells; + for (let i = 0; i < h.length; i++) { + if (h[i] < 20 || ownerOf(i) !== id) continue; + group + .append("polygon") + .attr("points", String(Pack.getPolygon(i))) + .attr("fill", color) + .attr("stroke", color); + } + } + + function setRowsInert(inert: boolean): void { + ensureEl(bodySectionId) + .querySelectorAll("div > input, select, span, svg") + .forEach(e => { + if (inert) e.style.pointerEvents = "none"; + else e.style.removeProperty("pointer-events"); + }); + } + + function finish(): void { + const parentId = parent; + const annexed = [...staged]; + exit(); + if (parentId && annexed.length) commit(parentId, annexed); + } + + function exit(): void { + if (!active) return; + active = false; + customization = 0; + parent = 0; + staged.clear(); + preview().remove(); + applyDefaultViewboxEvents(); + clearMainTip(); + setRowsInert(false); + ensureEl(buttonId).classList.remove("pressed"); + } + + return { toggle, exit }; +} diff --git a/src/components/slider-input.ts b/src/components/slider-input.ts index 8b7930df51..7bee8254d5 100644 --- a/src/components/slider-input.ts +++ b/src/components/slider-input.ts @@ -21,6 +21,8 @@ template.innerHTML = /* html */ ` `; class SliderInput extends HTMLElement { + static observedAttributes = ["min", "max", "step"]; + constructor() { super(); this.appendChild(template.content.cloneNode(true)); @@ -39,6 +41,11 @@ class SliderInput extends HTMLElement { number.addEventListener("change", this.handleEvent.bind(this)); } + attributeChangedCallback(name: string, _old: string | null, value: string | null) { + if (value === null) return; + for (const input of this.querySelectorAll("input")) input.setAttribute(name, value); + } + handleEvent(e: Event) { const value = (e.target as HTMLInputElement).value; const isInvalid = Number.isNaN(Number(value)); diff --git a/src/components/zoom.test.ts b/src/components/zoom.test.ts index f1b9920a4e..e52a268897 100644 --- a/src/components/zoom.test.ts +++ b/src/components/zoom.test.ts @@ -7,6 +7,7 @@ vi.mock("@/renderers/viewport/viewport-renderer", () => ({ })); import "@/generators/styles"; +import { ViewportLayers } from "@/renderers/viewport/viewport-renderer"; import { rn } from "@/utils/numberUtils"; import { applyZoomBehavior, setMapZoom } from "./zoom"; @@ -20,6 +21,7 @@ beforeEach(() => { + `; const map = document.getElementById("map")!; @@ -44,6 +46,8 @@ beforeEach(() => { vi.fn(() => 1) ); vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.mocked(ViewportLayers.schedule).mockClear(); + vi.mocked(ViewportLayers.renderNow).mockClear(); applyZoomBehavior(); }); @@ -56,6 +60,23 @@ describe("programmatic zoom", () => { }); }); +describe("viewport redraw during zoom", () => { + it("redraws viewport layers per frame and again when the gesture settles", () => { + setMapZoom(4); + + expect(ViewportLayers.schedule).toHaveBeenCalledTimes(1); + expect(ViewportLayers.renderNow).toHaveBeenCalledTimes(1); + }); + + it("skips the per-frame redraw when set to redraw after the zoom only", () => { + (document.getElementById("viewportRedraw") as HTMLSelectElement).value = "settled"; + setMapZoom(4); + + expect(ViewportLayers.schedule).not.toHaveBeenCalled(); + expect(ViewportLayers.renderNow).toHaveBeenCalledTimes(1); + }); +}); + describe("invokeActiveZooming", () => { beforeEach(() => { (document.getElementById("shapeRendering") as HTMLSelectElement).value = "auto"; diff --git a/src/components/zoom.ts b/src/components/zoom.ts index 45f816647a..ccbe5e6939 100644 --- a/src/components/zoom.ts +++ b/src/components/zoom.ts @@ -57,6 +57,8 @@ function handleZoomPerFrame(): void { } if (didPositionChange) Layers.draw("coordinates"); + + if (ensureEl("viewportRedraw").value === "continuous") ViewportLayers.schedule(); } /** Rewrite map content once zoom gesture settles */ @@ -78,8 +80,6 @@ function handleZoomEnd(): void { handleZoomPerFrame(); } - ViewportLayers.renderNow(); - invokeActiveZooming(); } diff --git a/src/controllers/help-assistant.test.ts b/src/controllers/help-assistant.test.ts new file mode 100644 index 0000000000..845de5e70f --- /dev/null +++ b/src/controllers/help-assistant.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HelpApiError } from "@/services/help/api"; +import { renderMarkdown } from "@/utils/markdown"; +import { buildFeedbackControl, limitsLabel, normalizeQuestion, noticeFor, shouldAutoRetry } from "./help-assistant"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("noticeFor", () => { + // Budget-refusal text is the server's to write (it carries wiki/Discord links as live + // markdown); the client must render it verbatim with nothing added. + const budgetCodes = ["cap_reached", "quota", "blocked"] as const; + it.each(budgetCodes)("renders %s server text verbatim as escaped markdown and disables asking", code => { + const message = "Budget used — see the [documentation](https://github.com/Azgaar/Fantasy-Map-Generator/wiki)."; + const notice = noticeFor(new HelpApiError(code, message)); + expect(notice.html).toBe(renderMarkdown(message)); + expect(notice.askDisabled).toBe(true); + expect(notice.retryCountdown).toBeUndefined(); + }); + + it("gives rate_limited a countdown from retryAfter", () => { + const notice = noticeFor(new HelpApiError("rate_limited", "Slow down.", 12)); + expect(notice.askDisabled).toBe(true); + expect(notice.retryCountdown).toBe(12); + }); + + it("defaults the rate_limited countdown to 30 when retryAfter is missing", () => { + expect(noticeFor(new HelpApiError("rate_limited", "Slow down.")).retryCountdown).toBe(30); + }); + + it("leaves asking enabled for provider_error and unreachable", () => { + expect(noticeFor(new HelpApiError("provider_error", "oops")).askDisabled).toBe(false); + expect(noticeFor(new HelpApiError("unreachable", "no net")).askDisabled).toBe(false); + }); + + it("escapes hostile markup in server messages", () => { + const notice = noticeFor(new HelpApiError("provider_error", '')); + expect(notice.html).not.toContain(" { + expect(noticeFor(new HelpApiError("unauthorized", "Session expired.")).askDisabled).toBe(false); + }); +}); + +describe("limitsLabel", () => { + const limits = (remaining: number) => ({ tier: "anonymous" as const, remaining, resetsAt: "2026-09-03T00:00:00Z" }); + it("pluralizes remaining questions", () => { + expect(limitsLabel(limits(5))).toBe("5 questions left today"); + expect(limitsLabel(limits(1))).toBe("1 question left today"); + expect(limitsLabel(limits(0))).toBe("No questions left today"); + }); +}); + +describe("shouldAutoRetry", () => { + it("auto-retries a rate_limited error with a server-sent retryAfter, once", () => { + const error = new HelpApiError("rate_limited", "Slow down.", 12); + expect(shouldAutoRetry(error, false)).toBe(true); + expect(shouldAutoRetry(error, true)).toBe(false); + }); + + it("never auto-retries on the client-default countdown (no server retryAfter)", () => { + const error = new HelpApiError("rate_limited", "Slow down."); + expect(shouldAutoRetry(error, false)).toBe(false); + }); + + it("never auto-retries non-rate_limited errors", () => { + expect(shouldAutoRetry(new HelpApiError("quota", "Budget used.", 12), false)).toBe(false); + expect(shouldAutoRetry(new HelpApiError("provider_error", "oops", 12), false)).toBe(false); + }); +}); + +describe("normalizeQuestion", () => { + it("trims and accepts 1 to 1000 characters", () => { + expect(normalizeQuestion(" how? ")).toBe("how?"); + expect(normalizeQuestion("a".repeat(1000))).toBe("a".repeat(1000)); + }); + it("rejects empty, whitespace-only, and overlong input", () => { + expect(normalizeQuestion("")).toBeNull(); + expect(normalizeQuestion(" \n ")).toBeNull(); + expect(normalizeQuestion("a".repeat(1001))).toBeNull(); + }); +}); + +describe("buildFeedbackControl", () => { + it("renders both thumbs unselected", () => { + const row = buildFeedbackControl(41); + const buttons = row.querySelectorAll("button"); + expect(buttons.length).toBe(2); + expect(row.querySelector(".selected")).toBeNull(); + for (const button of Array.from(buttons)) expect(button.getAttribute("aria-pressed")).toBe("false"); + }); + + it("marks the clicked rating selected and posts it", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + const row = buildFeedbackControl(41); + const [up, down] = Array.from(row.querySelectorAll("button")); + up.click(); + await Promise.resolve(); + expect(up.classList.contains("selected")).toBe(true); + expect(up.getAttribute("aria-pressed")).toBe("true"); + expect(down.getAttribute("aria-pressed")).toBe("false"); + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body).toEqual({ requestId: 41, rating: "up" }); + }); + + it("reverts the selection when the post fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("down"))); + const row = buildFeedbackControl(41); + const [up] = Array.from(row.querySelectorAll("button")); + up.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(up.classList.contains("selected")).toBe(false); + expect(up.getAttribute("aria-pressed")).toBe("false"); + }); + + it("moves the selection when the user switches rating", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); + const row = buildFeedbackControl(41); + const [up, down] = Array.from(row.querySelectorAll("button")); + up.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + down.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(up.classList.contains("selected")).toBe(false); + expect(up.getAttribute("aria-pressed")).toBe("false"); + expect(down.classList.contains("selected")).toBe(true); + expect(down.getAttribute("aria-pressed")).toBe("true"); + }); + + it("refreshes limits after an unauthorized feedback rejection (token already cleared by the transport)", async () => { + const fetchMock = vi.fn((url: string) => { + if (String(url).includes("/v1/feedback")) { + return Promise.resolve( + new Response(JSON.stringify({ error: { code: "unauthorized", message: "Session expired." } }), { + status: 401, + headers: { "Content-Type": "application/json" } + }) + ); + } + if (String(url).includes("/v1/limits")) { + return Promise.resolve( + new Response(JSON.stringify({ tier: "anonymous", remaining: 3, resetsAt: "2026-09-03T00:00:00Z" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }); + vi.stubGlobal("fetch", fetchMock); + + const row = buildFeedbackControl(41); + const [up] = Array.from(row.querySelectorAll("button")); + up.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(up.classList.contains("selected")).toBe(false); + expect(up.getAttribute("aria-pressed")).toBe("false"); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/v1/limits"))).toBe(true); + }); +}); diff --git a/src/controllers/help-assistant.ts b/src/controllers/help-assistant.ts new file mode 100644 index 0000000000..075d968fc0 --- /dev/null +++ b/src/controllers/help-assistant.ts @@ -0,0 +1,370 @@ +// First-party help assistant: asks the fmg-bot gateway one question at a time and renders the +// answer as escaped markdown. Replaces the OpenWidget bubble. Client design spec: +// docs/superpowers/specs/2026-09-01-help-box-client-design.md (fork repo). + +import { destroyDialog } from "@/components/dialog/dialog-helpers"; +import type { Limits } from "@/services/help/api"; +import { ask, getLimits, HelpApiError, OFFICIAL_ORIGIN, sendFeedback, signIn, signOut } from "@/services/help/api"; +import { getToken } from "@/services/help/auth"; +import { + adoptConversationId, + clearConversationId, + getConversationId, + isNewConversation +} from "@/services/help/conversation"; +import { renderMarkdown } from "@/utils/markdown"; +import { ensureEl } from "../utils"; + +export interface WidgetNotice { + html: string; + askDisabled: boolean; + retryCountdown?: number; +} + +const DEFAULT_RETRY_SECONDS = 30; + +// Declined states are designed states: the budget/quota text arrives display-ready from the +// server (with live links) and is rendered verbatim — never composed here. +export function noticeFor(error: HelpApiError): WidgetNotice { + const html = renderMarkdown(error.message); + switch (error.code) { + case "cap_reached": + case "quota": + case "blocked": + return { html, askDisabled: true }; + case "rate_limited": + return { html, askDisabled: true, retryCountdown: error.retryAfter ?? DEFAULT_RETRY_SECONDS }; + default: + return { html, askDisabled: false }; + } +} + +// One automatic retry only where the server sent a retryAfter — never on the client's default +// countdown, and never twice in a row for the same failure chain. +export function shouldAutoRetry(error: HelpApiError, alreadyRetried: boolean): boolean { + return error.code === "rate_limited" && error.retryAfter !== undefined && !alreadyRetried; +} + +export function limitsLabel(limits: Limits): string { + if (limits.remaining <= 0) return "No questions left today"; + return `${limits.remaining} question${limits.remaining === 1 ? "" : "s"} left today`; +} + +const MAX_QUESTION_LENGTH = 1000; + +export function normalizeQuestion(raw: string): string | null { + const question = raw.trim(); + if (!question.length || question.length > MAX_QUESTION_LENGTH) return null; + return question; +} + +const isOfficialOrigin = (): boolean => location.origin === OFFICIAL_ORIGIN || import.meta.env.DEV; + +function isMounted(): boolean { + return document.getElementById("helpAssistant") !== null; +} + +function open(): void { + renderDialog(); + + $("#helpAssistant").dialog({ + title: "Azgaar's Assistant", + position: { my: "center", at: "center", of: "svg" }, + width: Math.min(420, window.innerWidth - 20), // fixed sane width — FMG dialogs otherwise grow with content + resizable: false, + close: () => { + if (retryTimer) { + clearInterval(retryTimer); + retryTimer = null; + } + autoRetried = false; + destroyDialog("helpAssistant"); + } + }); + + if (isOfficialOrigin()) void refreshLimits(); +} + +function renderDialog(): void { + destroyDialog("helpAssistant"); + + const form = /* html */ ` + New chat +
+

Ask anything about using the Fantasy Map Generator.

+
+ + +
+ + + +
+
Questions are kept for 90 days to help improve the documentation.
`; + + // The community channels the OpenWidget panel used to offer — alternative ways to get help. + // GitHub points at the wiki (a turned-away user wants docs, not source). Patreon lives HERE + // deliberately and must NOT be added to the cap_reached text — that asymmetry is a ruling. + const links = /* html */ ` + `; + + // Self-hosted copies are not on the gateway's origin allowlist: explain, don't error + const unlisted = /* html */ ` +
+

The free assistant is only available on the official site: + + azgaar.github.io/Fantasy-Map-Generator.

+

On a self-hosted copy, the + documentation + covers most questions.

+
`; + + const html = /* html */ `
+ ${isOfficialOrigin() ? form : unlisted} + ${links} +
`; + ensureEl("dialogs").insertAdjacentHTML("beforeend", html); + + if (!isOfficialOrigin()) return; + ensureEl("helpAssistantAsk").addEventListener("click", () => void submit(normalizeQuestion(getQuestionInput()))); + ensureEl("helpAssistantQuestion").addEventListener("keydown", event => { + if ( + (event as KeyboardEvent).key === "Enter" && + ((event as KeyboardEvent).ctrlKey || (event as KeyboardEvent).metaKey) + ) { + void submit(normalizeQuestion(getQuestionInput())); + } + }); + ensureEl("helpAssistantNewChat").addEventListener("click", event => { + event.preventDefault(); + resetConversationLog(); + }); +} + +// Rollover must be SHOWN, not silent: whenever the conversation id is dropped, the old +// transcript is cleared too — otherwise the next exchange reads as one continuous thread +// that stopped making sense. Used by both "New chat" and sign-out; NOT sign-in (the page +// navigates away anyway). +function resetConversationLog(): void { + clearConversationId(); + const log = ensureEl("helpAssistantLog"); + log.textContent = ""; + const welcome = document.createElement("p"); + welcome.textContent = "Ask anything about using the Fantasy Map Generator."; + log.appendChild(welcome); + setNotice(null); +} + +function getQuestionInput(): string { + return ensureEl("helpAssistantQuestion").value; +} + +// isRetry marks an automatic re-submission of a rate-limited question after its countdown — +// distinct from the user clicking Ask again, which always starts a fresh retry chain. +async function submit(question: string | null, isRetry = false): Promise { + if (!question) return; + + const button = ensureEl("helpAssistantAsk"); + button.disabled = true; + button.textContent = "Asking…"; + if (!isRetry) appendEntry("helpAssistantAsked", question); + + const sentId = getConversationId(); + try { + const { answer, conversationId, requestId } = await ask(question, sentId ?? undefined); + const isNew = isNewConversation(sentId, conversationId); + // Pure storage — safe to do even if the dialog was closed during a slow ask, so it runs + // before the isMounted() guard: otherwise closing the dialog mid-ask would lose the + // server-issued id and silently orphan the conversation. + adoptConversationId(conversationId); + if (!isMounted()) return; + if (isNew) appendDivider(); + appendAnswer(renderMarkdown(answer), requestId); + ensureEl("helpAssistantQuestion").value = ""; + setNotice(null); + autoRetried = false; + } catch (error) { + if (!isMounted()) return; + if (error instanceof HelpApiError) { + // A poisoned/rejected id is the server's most likely reason for invalid_request — start + // the next ask clean rather than repeating the same 400 forever. + if (error.code === "invalid_request") clearConversationId(); + applyNotice(noticeFor(error), error, question); + } else console.error(error); + } finally { + if (isMounted()) { + if (!button.dataset.locked) { + button.disabled = false; + button.textContent = "Ask"; + } + void refreshLimits(); + } + } +} + +// The question is the user's own text: insert via textContent, never as markup +function appendEntry(className: string, text: string): void { + const entry = document.createElement("p"); + entry.className = className; + entry.textContent = text; + appendToLog(entry); +} + +function appendDivider(): void { + const divider = document.createElement("div"); + divider.className = "helpAssistantDivider"; + divider.textContent = "— new conversation —"; + appendToLog(divider); +} + +// renderMarkdown output only — the renderer escapes every leaf +function appendAnswer(safeHtml: string, requestId: number | null): void { + const entry = document.createElement("div"); + entry.className = "helpAssistantAnswer"; + entry.innerHTML = safeHtml; + // requestId null means there is nothing server-side to rate — no control (never post null) + if (requestId !== null) entry.appendChild(buildFeedbackControl(requestId)); + appendToLog(entry); +} + +export function buildFeedbackControl(requestId: number): HTMLElement { + const row = document.createElement("div"); + row.className = "helpAssistantFeedback"; + + for (const rating of ["up", "down"] as const) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = rating === "up" ? "👍" : "👎"; + button.setAttribute("aria-label", rating === "up" ? "Good answer" : "Bad answer"); + button.setAttribute("aria-pressed", "false"); + button.addEventListener("click", () => { + const previous = row.querySelector(".selected"); + previous?.classList.remove("selected"); + previous?.setAttribute("aria-pressed", "false"); + button.classList.add("selected"); + button.setAttribute("aria-pressed", "true"); + // a failed post is a silent nicety-miss: revert the selection, never a widget state + sendFeedback(requestId, rating).catch((error: unknown) => { + button.classList.remove("selected"); + button.setAttribute("aria-pressed", "false"); + previous?.classList.add("selected"); + previous?.setAttribute("aria-pressed", "true"); + // the shared transport already cleared the token on a 401 — resync the footer + // instead of leaving it stuck claiming "Signed in" + if (error instanceof HelpApiError && error.code === "unauthorized") void refreshLimits(); + }); + }); + row.appendChild(button); + } + return row; +} + +function appendToLog(node: HTMLElement): void { + const log = ensureEl("helpAssistantLog"); + log.appendChild(node); + log.scrollTop = log.scrollHeight; +} + +function setNotice(safeHtml: string | null): void { + const notice = ensureEl("helpAssistantNotice"); + notice.hidden = safeHtml === null; + notice.innerHTML = safeHtml ?? ""; +} + +let retryTimer: ReturnType | null = null; +let autoRetried = false; + +function applyNotice(notice: WidgetNotice, error: HelpApiError, question: string): void { + setNotice(notice.html); + const button = ensureEl("helpAssistantAsk"); + if (retryTimer) clearInterval(retryTimer); + + if (!notice.askDisabled) return; + button.disabled = true; + button.dataset.locked = "true"; + + if (notice.retryCountdown === undefined) { + button.textContent = "Ask"; // stable label — cap_reached/quota/blocked have no countdown + return; + } + + const autoRetry = shouldAutoRetry(error, autoRetried); + let secondsLeft = notice.retryCountdown; + button.textContent = `Wait ${secondsLeft}s`; + retryTimer = setInterval(() => { + secondsLeft -= 1; + if (secondsLeft > 0) { + button.textContent = `Wait ${secondsLeft}s`; + return; + } + if (retryTimer) clearInterval(retryTimer); + retryTimer = null; + delete button.dataset.locked; + button.disabled = false; + button.textContent = "Ask"; + setNotice(null); + if (autoRetry && isMounted()) { + autoRetried = true; + void submit(question, true); + } + }, 1000); +} + +// Sign-in is shown only on the exact official origin (or DEV, where the stub closes the +// loop) — NOT via isOfficialOrigin(): a staging build widens that gate, and sign-in from +// staging would land the user on production with their token (server redirect is fixed). +const canSignIn = (): boolean => import.meta.env.DEV || location.origin === OFFICIAL_ORIGIN; + +function renderAuth(tier: string): void { + const host = document.getElementById("helpAssistantAuth"); + if (!host) return; + host.textContent = ""; + + if (tier === "anonymous") { + if (!canSignIn()) return; + const button = document.createElement("button"); + button.textContent = "Sign in with Discord for more"; + button.addEventListener("click", () => { + clearConversationId(); + signIn(); + }); + host.appendChild(button); + return; + } + + const label = document.createElement("span"); + label.textContent = `Signed in (${tier}) · `; + const out = document.createElement("a"); + out.href = "#"; + out.textContent = "Sign out"; + out.addEventListener("click", event => { + event.preventDefault(); + void signOut().then(() => { + resetConversationLog(); + void refreshLimits(); + }); + }); + host.appendChild(label); + host.appendChild(out); +} + +async function refreshLimits(): Promise { + try { + const limits = await getLimits(); + ensureEl("helpAssistantLimits").textContent = limitsLabel(limits); + renderAuth(limits.tier); + } catch { + // limits are a nicety; asking still reports the authoritative state. Render auth from + // local state rather than dropping it — a signed-in user must keep the sign-out affordance + // even when /v1/limits is failing. + renderAuth(getToken() ? "member" : "anonymous"); + } +} + +export const HelpAssistant = { open }; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index b00e9f1b45..a95b06945a 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -24,6 +24,7 @@ export const Controllers = createRegistry({ GoodsEditor: () => import("@/controllers/goods-editor").then(m => m.GoodsEditor), HeightmapEditor: () => import("@/controllers/heightmap-editor").then(m => m.HeightmapEditor), HeightmapSelection: () => import("@/controllers/heightmap-selection").then(m => m.HeightmapSelection), + HelpAssistant: () => import("@/controllers/help-assistant").then(m => m.HelpAssistant), IconSelector: () => import("@/controllers/icon-selector").then(m => m.IconSelector), HierarchyTree: () => import("@/controllers/hierarchy-tree").then(m => m.HierarchyTree), IceEditor: () => import("@/controllers/ice-editor").then(m => m.IceEditor), diff --git a/src/controllers/provinces-editor.ts b/src/controllers/provinces-editor.ts index c802961252..713ffac574 100644 --- a/src/controllers/provinces-editor.ts +++ b/src/controllers/provinces-editor.ts @@ -1,4 +1,5 @@ -import { color as d3Color, easeSinIn, interpolate, interpolateString, select, stratify, transition, treemap } from "d3"; +import { color as d3Color, easeSinIn, interpolate, select, stratify, transition, treemap } from "d3"; +import { createAnnexMode } from "@/components/annex-mode"; import { closeDialogs, confirmationDialog, destroyDialog, updateDialog } from "@/components/dialog/dialog-helpers"; import { applyLineHighlighting } from "@/components/dialog/highlighting"; import { bindColumnSorting, sortDataByColumns } from "@/components/dialog/sorting"; @@ -21,7 +22,7 @@ import type { Province } from "@/generators/provinces-generator"; import { redrawEmblem, redrawEmblems, removeEmblem } from "@/renderers/draw-emblems"; import { EmblemRenderer } from "@/renderers/emblems/renderer"; import { fog, unfog } from "@/renderers/overlays/fogging"; -import { highlightElement } from "@/renderers/overlays/highlight"; +import { highlightElement, highlightOutline } from "@/renderers/overlays/highlight"; import { applyOption, downloadFile, getArea, getAreaUnit, getFileName, speak } from "@/utils"; import { ensureEl, findEl, getPointer, getRandomColor, isLand, P, rand, rn, si, unique } from "../utils"; @@ -156,6 +157,11 @@ function renderDialog(): void { class="icon-plus" > + + `; @@ -260,6 +262,7 @@ function renderDialog(): void { ensureEl("statesManuallyCancel").addEventListener("click", () => exitStatesManualAssignment(false)); ensureEl("statesAdd").addEventListener("click", enterAddStateMode); ensureEl("statesMerge").addEventListener("click", openStateMergeDialog); + ensureEl("statesAnnex").addEventListener("click", statesAnnex.toggle); ensureEl("statesExport").addEventListener("click", downloadStatesCsv); ensureEl("statesBodySection").addEventListener("click", event => { @@ -308,6 +311,7 @@ function renderDialog(): void { function closeStatesEditor(): void { if (customization === 2) exitStatesManualAssignment(true); if (customization === 3) exitAddStateMode(); + statesAnnex.exit(); select("#debug").selectAll(".highlight").remove(); $("#statesEditor").dialog("destroy"); ensureEl("statesEditor").remove(); @@ -526,25 +530,7 @@ function stateHighlightOn(event: any): void { const state = +event.target.dataset.id; if (customization || !state) return; - const d = select("#regions").select(`#state${state}`).attr("d"); - - const path = select("#debug") - .append("path") - .attr("class", "highlight") - .attr("d", d) - .attr("fill", "none") - .attr("stroke", "red") - .attr("stroke-width", 1) - .attr("opacity", 1) - .attr("filter", "url(#blur1)"); - - const totalLength = (path.node() as SVGPathElement).getTotalLength(); - const duration = (totalLength + 5000) / 2; - const interpolate = interpolateString(`0, ${totalLength}`, `${totalLength}, ${totalLength}`); - path - .transition() - .duration(duration) - .attrTween("stroke-dasharray", () => interpolate); + highlightOutline(select("#regions").select(`#state${state}`).attr("d")); } function stateHighlightOff(): void { @@ -1911,9 +1897,10 @@ function exitAddStateMode(): void { if (statesAdd.classList.contains("pressed")) statesAdd.classList.remove("pressed"); } +const stateEmblem = (i: number) => + /* html */ ``; + function openStateMergeDialog(): void { - const emblem = (i: number) => - /* html */ ``; // Mirror the editor's active sort so the merge list reads in the same order the user is // looking at (e.g. by culture), instead of always by state id. const validStates = sortDataByColumns( @@ -1928,7 +1915,7 @@ function openStateMergeDialog(): void {
- +
` ) @@ -1967,24 +1954,7 @@ function openStateMergeDialog(): void { if (!d) return; stateHighlightOff(); - - const path = select("#debug") - .append("path") - .attr("class", "highlight") - .attr("d", d) - .attr("fill", "none") - .attr("stroke", "red") - .attr("stroke-width", 1) - .attr("opacity", 1) - .attr("filter", "url(#blur1)"); - - const totalLength = (path.node() as SVGPathElement).getTotalLength(); - const duration = (totalLength + 5000) / 2; - const interpolate = interpolateString(`0, ${totalLength}`, `${totalLength}, ${totalLength}`); - path - .transition() - .duration(duration) - .attrTween("stroke-dasharray", () => interpolate); + highlightOutline(d); } $("#alert").dialog({ @@ -2000,7 +1970,6 @@ function openStateMergeDialog(): void { tip("Please select a state to merge into", false, "error"); return; } - const rullingState = pack.states[rulingStateId]; const statesToMerge = formData .getAll("statesToMerge") @@ -2012,28 +1981,7 @@ function openStateMergeDialog(): void { } const mergeToProvinces = formData.has("mergeToProvinces"); - - const mergedList = statesToMerge.map(stateId => `${emblem(stateId)}${pack.states[stateId].name}`).join(", "); - // prettier-ignore - const message = mergeToProvinces - ? /* html */ ` -

The following states will lose their state status and each become a single province of ${emblem(rullingState.i)}${rullingState.name}: ${mergedList}.

-

Their burgs, regiments and lands (along with any existing internal provinces, which are collapsed into the new province) will be assigned to ${emblem(rullingState.i)}${rullingState.name}.

-

Are you sure you want to merge states? This action cannot be reverted.

` - : /* html */ ` -

The following states will be removed: ${mergedList}.

-

Removed states data (burgs, provinces, regiments) will be assigned to ${emblem(rullingState.i)}${rullingState.name}.

-

Are you sure you want to merge states? This action cannot be reverted.

`; - - confirmationDialog({ - title: "Merge states", - message, - confirm: "Merge", - onConfirm: () => { - mergeStates(statesToMerge, rulingStateId, mergeToProvinces); - $(this).dialog("close"); - } - }); + confirmStatesMerge(statesToMerge, rulingStateId, () => $(this).dialog("close"), mergeToProvinces); }, Cancel: function (this: HTMLElement) { $(this).dialog("close"); @@ -2042,6 +1990,46 @@ function openStateMergeDialog(): void { }); } +function confirmStatesMerge( + statesToMerge: number[], + rulingStateId: number, + onConfirm?: () => void, + mergeToProvinces = false +): void { + const rulingState = pack.states[rulingStateId]; + const ruler = `${stateEmblem(rulingState.i)}${rulingState.name}`; + const mergedList = statesToMerge.map(stateId => `${stateEmblem(stateId)}${pack.states[stateId].name}`).join(", "); + // prettier-ignore + const message = mergeToProvinces + ? /* html */ ` +

The following states will lose their state status and each become a single province of ${ruler}: ${mergedList}.

+

Their burgs, regiments and lands (along with any existing internal provinces, which are collapsed into the new province) will be assigned to ${ruler}.

+

Are you sure you want to merge states? This action cannot be reverted.

` + : /* html */ ` +

The following states will be removed: ${mergedList}.

+

Removed states data (burgs, provinces, regiments) will be assigned to ${ruler}.

+

Are you sure you want to merge states? This action cannot be reverted.

`; + confirmationDialog({ + title: "Merge states", + message, + confirm: "Merge", + onConfirm: () => { + mergeStates(statesToMerge, rulingStateId, mergeToProvinces); + onConfirm?.(); + } + }); +} + +const statesAnnex = createAnnexMode({ + buttonId: "statesAnnex", + bodySectionId: "statesBodySection", + noun: "state", + ownerOf: cellId => pack.cells.state[cellId], + colorOf: stateId => pack.states[stateId].color ?? "#999999", + nameOf: stateId => pack.states[stateId].name, + commit: (rulingStateId, statesToMerge) => confirmStatesMerge(statesToMerge, rulingStateId) +}); + // Merge `statesToMerge` into `rulingStateId`. With `mergeToProvinces`, each merged state is demoted // into a single province of the ruling state (keeping its name/form/colour/emblem) instead of being // dissolved. At module scope so both the merge dialog and the paint-mode picker can call it. @@ -2104,10 +2092,6 @@ function mergeStates(statesToMerge: number[], rulingStateId: number, mergeToProv statesToMerge.forEach(stateId => { const state = pack.states[stateId]; state.removed = true; - - select("#statesBody").select(`#state${stateId}`).remove(); - select("#statesBody").select(`#state-gap${stateId}`).remove(); - select("#statesHalo").select(`#state-border${stateId}`).remove(); delete pack.states[stateId].label; removeEmblem("state", stateId); @@ -2166,19 +2150,14 @@ function mergeStates(statesToMerge: number[], rulingStateId: number, mergeToProv select("#debug").selectAll(".highlight").remove(); States.getPoles(); - Layers.show("states", "borders"); - // When demoting to provinces, force the provinces layer on so the newly created province is - // actually visible. Otherwise (layer off) the result looks identical to a plain merge. - if (mergeToProvinces) { - Provinces.getPoles(); - Layers.show("provinces"); - } else { - Layers.draw("provinces"); - } + if (mergeToProvinces) Provinces.getPoles(); if (!pack.states[rulingStateId].label) delete pack.states[rulingStateId].label; - drawLabels(); + Layers.draw("states", "borders", "burgIcons", "labels", "provinces"); + // When demoting to provinces, force the provinces layer on so the newly created province is + // actually visible. Otherwise (layer off) the result looks identical to a plain merge. + if (mergeToProvinces) Layers.show("provinces"); refreshStatesEditor(); } diff --git a/src/index.html b/src/index.html index 5781ca2ec1..91beac6843 100644 --- a/src/index.html +++ b/src/index.html @@ -129,7 +129,7 @@ + + Redraw on zoom + + + + + @@ -2174,6 +2187,8 @@ +