"@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",
"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",
--- /dev/null
+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;
+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';
};
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') {
}
}, [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"
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>
<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>;
};
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');
if (svg) {
return <g
className={classNames.join(' ')}
+ data-icon={icon}
onClick={(e) => {
activeController.handlePrimary(state, setManualState, icons);
e.preventDefault();
e.stopPropagation();
}}
>
- <ZeldaIcon name={active || defaultIcon || icons[0]} svg />
+ <ZeldaIcon name={icon} svg />
</g>;
}
return <span
handleSecondary: doNothing,
};
+ToggleIcon.pinController = (pin, removePin) => ({
+ getActive: firstIcon,
+ getDefault: firstIcon,
+ handlePrimary: doNothing,
+ handleSecondary: () => removePin(pin),
+});
+
ToggleIcon.simpleController = {
getActive: highestActive,
getDefault: firstIcon,
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 => {
});
}, []);
+ 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) {
}, [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}
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;
> * {