]> git.localhorst.tv Git - alttp.git/commitdiff
draggable tracker icons
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 5 Apr 2024 15:17:38 +0000 (17:17 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 5 Apr 2024 15:17:38 +0000 (17:17 +0200)
package-lock.json
package.json
resources/js/components/common/AspectBox.js [new file with mode: 0644]
resources/js/components/tracker/Canvas.js
resources/js/components/tracker/ToggleIcon.js
resources/js/hooks/tracker.js
resources/sass/common.scss

index f0de6617f4c11e328c75c8e11214cf4cac534a8b..e86ecd003a4ff28486abf780e7b24054a75fa814 100644 (file)
@@ -15,6 +15,7 @@
                 "@uiw/react-codemirror": "^4.21.9",
                 "apng-js": "^1.1.1",
                 "crc-32": "^1.2.2",
+                "d3-drag": "^3.0.0",
                 "file-saver": "^2.0.5",
                 "formik": "^2.2.9",
                 "i18next": "^23.4.9",
                 "node": ">=12"
             }
         },
+        "node_modules/d3-dispatch": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+            "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/d3-drag": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+            "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+            "dependencies": {
+                "d3-dispatch": "1 - 3",
+                "d3-selection": "3"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
         "node_modules/d3-ease": {
             "version": "3.0.1",
             "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
                 "node": ">=12"
             }
         },
+        "node_modules/d3-selection": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+            "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+            "engines": {
+                "node": ">=12"
+            }
+        },
         "node_modules/d3-shape": {
             "version": "3.2.0",
             "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
             "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
             "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
         },
+        "d3-dispatch": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+            "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="
+        },
+        "d3-drag": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+            "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+            "requires": {
+                "d3-dispatch": "1 - 3",
+                "d3-selection": "3"
+            }
+        },
         "d3-ease": {
             "version": "3.0.1",
             "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
                 "d3-time-format": "2 - 4"
             }
         },
+        "d3-selection": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+            "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="
+        },
         "d3-shape": {
             "version": "3.2.0",
             "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
index 1fdb3627e79f0f4e784d63b6d226fc6587c60d3d..00152a895b8f857733e6cbe0df9bfedba45904ec 100644 (file)
@@ -45,7 +45,7 @@
                     "tabWidth": 4
                 }
             ],
-                       "no-use-before-define": "error",
+            "no-use-before-define": "error",
             "no-extra-parens": [
                 "warn",
                 "all",
                 "env": {
                     "jest": true
                 },
-                               "settings": {
-                                       "import/resolver": {
-                                               "node": {
-                                                       "paths": [
-                                                               "resources/js"
-                                                       ]
-                                               }
-                                       }
-                               }
+                "settings": {
+                    "import/resolver": {
+                        "node": {
+                            "paths": [
+                                "resources/js"
+                            ]
+                        }
+                    }
+                }
             }
         ]
     },
     "jest": {
-               "moduleDirectories": [
-                       "node_modules",
-                       "resources/js"
-               ],
+        "moduleDirectories": [
+            "node_modules",
+            "resources/js"
+        ],
         "roots": [
             "<rootDir>/resources/js",
             "<rootDir>/tests/js"
         "@uiw/react-codemirror": "^4.21.9",
         "apng-js": "^1.1.1",
         "crc-32": "^1.2.2",
+        "d3-drag": "^3.0.0",
         "file-saver": "^2.0.5",
         "formik": "^2.2.9",
         "i18next": "^23.4.9",
diff --git a/resources/js/components/common/AspectBox.js b/resources/js/components/common/AspectBox.js
new file mode 100644 (file)
index 0000000..52709c1
--- /dev/null
@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const AspectBox = ({ children, ratio }) =>
+       <div className="aspect-box-container" style={{ paddingTop: `${1 / ratio * 100}%`}}>
+               <div className="aspect-box-content">
+                       {children}
+               </div>
+       </div>;
+
+AspectBox.propTypes = {
+       children: PropTypes.node,
+       ratio: PropTypes.number,
+};
+
+AspectBox.defaultProps = {
+       children: null,
+       ratio: 1,
+};
+
+export default AspectBox;
index 392f2a7dc63f35ca27af8bfc71476e96d9eecae3..fab194219c7d56ec9dee81d34c27b5cce9a2c340 100644 (file)
@@ -1,8 +1,12 @@
+import { drag } from 'd3-drag';
+import { select } from 'd3-selection';
 import React from 'react';
 
 import Dungeons from './Dungeons';
 import Items from './Items';
 import Map from './Map';
+import ToggleIcon from './ToggleIcon';
+import ZeldaIcon from '../common/ZeldaIcon';
 import { shouldShowDungeonItem } from '../../helpers/tracker';
 import { useTracker } from '../../hooks/tracker';
 
@@ -34,7 +38,8 @@ const LAYOUTS = {
 };
 
 const Canvas = () => {
-       const { config } = useTracker();
+       const [dragging, setDragging] = React.useState(null);
+       const { addPin, config, pins, removePin } = useTracker();
 
        const layout = React.useMemo(() => {
                if (config.mapLayout === 'vertical') {
@@ -57,6 +62,62 @@ const Canvas = () => {
                }
        }, [config]);
 
+       React.useEffect(() => {
+               const canvas = select('.canvas');
+               const bbox = canvas.select('.background');
+               const start = { x: 0, y: 0 };
+               const onStart = function (e) {
+                       const bounds = bbox.node().getBoundingClientRect();
+                       start.x = e.x;
+                       start.y = e.y;
+                       setDragging({
+                               icon: this.dataset['icon'],
+                               x: (e.x - bounds.x) / bounds.width,
+                               y: (e.y - bounds.y) / bounds.height,
+                       });
+               };
+               const onDrag = function (e) {
+                       const bounds = bbox.node().getBoundingClientRect();
+                       setDragging({
+                               icon: this.dataset['icon'],
+                               x: (e.x - bounds.x) / bounds.width,
+                               y: (e.y - bounds.y) / bounds.height,
+                       });
+               };
+               const onEnd = function (e) {
+                       const bounds = bbox.node().getBoundingClientRect();
+                       setDragging(null);
+                       const distance = Math.max(Math.abs(e.x - start.x), Math.abs(e.y - start.y));
+                       if (distance > 5) {
+                               addPin({
+                                       icon: this.dataset['icon'],
+                                       x: (e.x - bounds.x) / bounds.width,
+                                       y: (e.y - bounds.y) / bounds.height,
+                               });
+                               if (this.classList.contains('map-pin')) {
+                                       let id = 0;
+                                       this.classList.forEach(name => {
+                                               if (name.startsWith('map-pin-')) {
+                                                       id = parseInt(name.substr(8), 10);
+                                               }
+                                       });
+                                       removePin({ id });
+                               }
+                       }
+               };
+               const selection = canvas.selectAll('.toggle-icon');
+               const draggable = drag()
+                       .container(bbox)
+                       .clickDistance(5)
+                       .on('start', onStart)
+                       .on('drag', onDrag)
+                       .on('end', onEnd);
+               selection.call(draggable);
+               return () => {
+                       selection.on('.drag', null);
+               };
+       }, [pins, removePin]);
+
        return <svg
                xmlns="http://www.w3.org/2000/svg"
                className="canvas"
@@ -68,6 +129,13 @@ const Canvas = () => {
                        e.stopPropagation();
                }}
        >
+               <rect
+                       className="background"
+                       fill="transparent"
+                       x="0" y="0"
+                       width={layout.width}
+                       height={layout.height}
+               />
                <g className="items" transform={layout.itemsTransform}>
                        <Items />
                </g>
@@ -77,6 +145,30 @@ const Canvas = () => {
                <g className="tracker-map" transform={layout.mapTransform}>
                        <Map />
                </g>
+               <g className="pins">
+                       {pins.map(pin =>
+                               <g
+                                       key={pin.id}
+                                       transform={
+                                               `translate(${pin.x * layout.width} ${pin.y * layout.height}) scale(3)`
+                                       }
+                               >
+                                       <ToggleIcon
+                                               className={`map-pin map-pin-${pin.id}`}
+                                               controller={ToggleIcon.pinController(pin, removePin)}
+                                               icons={[pin.icon]}
+                                               svg
+                                       />
+                               </g>
+                       )}
+               </g>
+               {dragging ?
+                       <g transform={
+                               `translate(${dragging.x * layout.width} ${dragging.y * layout.height}) scale(4)`
+                       }>
+                               <ZeldaIcon name={dragging.icon} svg />
+                       </g>
+               : null}
        </svg>;
 };
 
index 98ae93240d0656087f0dd17719c9af89a9b80da1..0d8afa4068440afcd962fe3d5fe9c971506d2f32 100644 (file)
@@ -24,6 +24,7 @@ const ToggleIcon = ({ controller, className, icons, svg }) => {
        const activeController = controller || ToggleIcon.nullController;
        const active = activeController.getActive(state, icons);
        const defaultIcon = activeController.getDefault(state, icons);
+       const icon = active || defaultIcon || icons[0];
        const classNames = ['toggle-icon'];
        if (active) {
                classNames.push('active');
@@ -36,6 +37,7 @@ const ToggleIcon = ({ controller, className, icons, svg }) => {
        if (svg) {
                return <g
                        className={classNames.join(' ')}
+                       data-icon={icon}
                        onClick={(e) => {
                                activeController.handlePrimary(state, setManualState, icons);
                                e.preventDefault();
@@ -47,7 +49,7 @@ const ToggleIcon = ({ controller, className, icons, svg }) => {
                                e.stopPropagation();
                        }}
                >
-                       <ZeldaIcon name={active || defaultIcon || icons[0]} svg />
+                       <ZeldaIcon name={icon} svg />
                </g>;
        }
        return <span
@@ -265,6 +267,13 @@ ToggleIcon.nullController = {
        handleSecondary: doNothing,
 };
 
+ToggleIcon.pinController = (pin, removePin) => ({
+       getActive: firstIcon,
+       getDefault: firstIcon,
+       handlePrimary: doNothing,
+       handleSecondary: () => removePin(pin),
+});
+
 ToggleIcon.simpleController = {
        getActive: highestActive,
        getDefault: firstIcon,
index ce01c4b94079dd646e17f9263882a85544bc3153..d0fb6b8e028e400bb4f0f3287eeebb3df6e5a1f4 100644 (file)
@@ -21,6 +21,7 @@ export const TrackerProvider = ({ children }) => {
        const [manualState, setManualState] = React.useState(makeEmptyState());
        const [dungeons, setDungeons] = React.useState(DUNGEONS);
        const [logic, setLogic] = React.useState({});
+       const [pins, setPins] = React.useState([]);
 
        const saveConfig = React.useCallback((values) => {
                setConfig(s => {
@@ -30,6 +31,17 @@ export const TrackerProvider = ({ children }) => {
                });
        }, []);
 
+       const addPin = React.useCallback((pin) => {
+               setPins(ps => {
+                       const id = ps.length ? ps[ps.length - 1].id + 1 : 1;
+                       return [...ps, { ...pin, id }];
+               });
+       }, []);
+
+       const removePin = React.useCallback((pin) => {
+               setPins(ps => ps.filter(p => p.id !== pin.id));
+       }, []);
+
        React.useEffect(() => {
                const savedConfig = localStorage.getItem('tracker.config');
                if (savedConfig) {
@@ -51,8 +63,19 @@ export const TrackerProvider = ({ children }) => {
        }, [config, dungeons, state]);
 
        const value = React.useMemo(() => {
-               return { config, dungeons, logic, saveConfig, setAutoState, setManualState, state };
-       }, [config, dungeons, logic, state]);
+               return {
+                       addPin,
+                       config,
+                       dungeons,
+                       logic,
+                       pins,
+                       removePin,
+                       saveConfig,
+                       setAutoState,
+                       setManualState,
+                       state,
+               };
+       }, [config, dungeons, logic, pins, state]);
 
        return <context.Provider value={value}>
                {children}
index 0d61af3f5605901eda06ee3f67b8b8cf17d09a63..1f73fb450a0c0ca07ba6390e6393f6ad7625b98f 100644 (file)
@@ -11,6 +11,18 @@ h1 {
        margin-bottom: 2rem;
 }
 
+.aspect-box-container {
+       position: relative;
+       padding-top: 100%;
+}
+.aspect-box-content {
+       position: absolute;
+       top: 0;
+       left: 0;
+       width: 100%;
+       height: 100%;
+}
+
 .button-bar {
        margin: -0.5ex;
        > * {