]> git.localhorst.tv Git - alttp.git/commitdiff
basic map pins
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 4 Feb 2023 13:12:12 +0000 (14:12 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 4 Feb 2023 13:12:12 +0000 (14:12 +0100)
14 files changed:
app/Http/Controllers/TechniqueController.php
app/Models/TechniqueMap.php [new file with mode: 0644]
database/migrations/2023_02_01_153251_technique_map.php [new file with mode: 0644]
resources/js/components/common/Icon.js
resources/js/components/map/Buttons.js
resources/js/components/map/OpenSeadragon.js
resources/js/components/map/Overlay.js [new file with mode: 0644]
resources/js/components/map/Pin.js [new file with mode: 0644]
resources/js/components/map/Pins.js [new file with mode: 0644]
resources/js/components/map/Popover.js [new file with mode: 0644]
resources/js/components/pages/Map.js
resources/sass/app.scss
resources/sass/map.scss [new file with mode: 0644]
routes/api.php

index 8694c046de5e1dd1520158c883e55ef8486a52bd..b27465e5da1afe0819198cc88338e7f23ce80282 100644 (file)
@@ -3,12 +3,19 @@
 namespace App\Http\Controllers;
 
 use App\Models\Technique;
+use App\Models\TechniqueMap;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\Request;
 
 class TechniqueController extends Controller
 {
 
+       public function forMap($map) {
+               $techs = TechniqueMap::with(['technique', 'technique.relations'])->where('map', '=', $map);
+
+               return $techs->get()->toJson();
+       }
+
        public function search(Request $request) {
                $validatedData = $request->validate([
                        'phrase' => 'string|nullable',
diff --git a/app/Models/TechniqueMap.php b/app/Models/TechniqueMap.php
new file mode 100644 (file)
index 0000000..d19a005
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class TechniqueMap extends Model
+{
+       use HasFactory;
+
+       public function technique() {
+               return $this->belongsTo(Technique::class);
+       }
+
+       protected $hidden = [
+               'created_at',
+               'updated_at',
+       ];
+
+}
diff --git a/database/migrations/2023_02_01_153251_technique_map.php b/database/migrations/2023_02_01_153251_technique_map.php
new file mode 100644 (file)
index 0000000..42e9230
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::create('technique_maps', function (Blueprint $table) {
+                       $table->id();
+                       $table->foreignId('technique_id')->constrained();
+                       $table->string('map');
+                       $table->double('x');
+                       $table->double('y');
+                       $table->timestamps();
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::dropIfExists('technique_maps');
+       }
+};
index bce75f58dd79bbfa35fcda17117d8ee859e033b4..c9d604abd453fb3ecf41691d3147f28f450f2bd0 100644 (file)
@@ -73,6 +73,7 @@ Icon.LANGUAGE = makePreset('LanguageIcon', 'language');
 Icon.LOCKED = makePreset('LockedIcon', 'lock');
 Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
 Icon.PENDING = makePreset('PendingIcon', 'clock');
+Icon.PIN = makePreset('PinIcon', 'location-pin');
 Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
 Icon.REJECT = makePreset('RejectIcon', 'square-xmark');
 Icon.REMOVE = makePreset('RemoveIcon', 'square-xmark');
index b43fc0e64d8c3d9a772bbbfca408ad1536f8d59a..fdffe92cdbfd57b75f7daeb14f6d7e7fb79c765c 100644 (file)
@@ -5,42 +5,21 @@ import { useTranslation } from 'react-i18next';
 import { useOpenSeadragon } from './OpenSeadragon';
 
 const Buttons = () => {
-       const { viewer } = useOpenSeadragon();
+       const { activeMap, setActiveMap } = useOpenSeadragon();
        const { t } = useTranslation();
 
-       const goToPage = React.useCallback((p) => {
-               if (viewer) viewer.goToPage(p);
-       }, [viewer]);
-
        return <div className="button-bar">
-               <Button
-                       onClick={() => goToPage(0)}
-                       title={t('map.lwLong')}
-                       variant="outline-secondary"
-               >
-                       {t('map.lwShort')}
-               </Button>
-               <Button
-                       onClick={() => goToPage(1)}
-                       title={t('map.dwLong')}
-                       variant="outline-secondary"
-               >
-                       {t('map.dwShort')}
-               </Button>
-               <Button
-                       onClick={() => goToPage(2)}
-                       title={t('map.spLong')}
-                       variant="outline-secondary"
-               >
-                       {t('map.spShort')}
-               </Button>
-               <Button
-                       onClick={() => goToPage(3)}
-                       title={t('map.uwLong')}
-                       variant="outline-secondary"
-               >
-                       {t('map.uwShort')}
-               </Button>
+               {['lw', 'dw', 'sp', 'uw'].map(map =>
+                       <Button
+                               active={activeMap === map}
+                               key={map}
+                               onClick={() => setActiveMap(map)}
+                               title={t(`map.${map}Long`)}
+                               variant="outline-secondary"
+                       >
+                               {t(`map.${map}Short`)}
+                       </Button>
+               )}
        </div>;
 };
 
index f8543189e18c3b71ea4344be4e3ba0634d58fbc1..b6158a5aea79ca3a03f964deb4f5dc413cfa52c0 100644 (file)
@@ -1,3 +1,4 @@
+import axios from 'axios';
 import OpenSeadragon from 'openseadragon';
 import PropTypes from 'prop-types';
 import React from 'react';
@@ -7,6 +8,8 @@ export const Context = React.createContext({});
 export const useOpenSeadragon = () => React.useContext(Context);
 
 export const Provider = React.forwardRef(({ children }, ref) => {
+       const [activeMap, setActiveMap] = React.useState('lw');
+       const [pins, setPins] = React.useState([]);
        const [viewer, setViewer] = React.useState(null);
 
        React.useEffect(() => {
@@ -65,7 +68,38 @@ export const Provider = React.forwardRef(({ children }, ref) => {
                };
        }, [ref.current]);
 
-       return <Context.Provider value={{ viewer }}>
+       React.useEffect(() => {
+               if (!viewer) return;
+               switch (activeMap) {
+                       case 'lw':
+                               viewer.goToPage(0);
+                               break;
+                       case 'dw':
+                               viewer.goToPage(1);
+                               break;
+                       case 'sp':
+                               viewer.goToPage(2);
+                               break;
+                       case 'uw':
+                               viewer.goToPage(3);
+                               break;
+               }
+               const controller = new AbortController();
+               axios.get(`/api/markers/${activeMap}`, {
+                       signal: controller.signal,
+               }).then(response => {
+                       setPins(response.data || []);
+               }).catch(e => {
+                       if (!axios.isCancel(e)) {
+                               console.error(e);
+                       }
+               });
+               return () => {
+                       controller.abort();
+               };
+       }, [activeMap, viewer]);
+
+       return <Context.Provider value={{ activeMap, setActiveMap, pins, viewer }}>
                {children}
        </Context.Provider>;
 });
diff --git a/resources/js/components/map/Overlay.js b/resources/js/components/map/Overlay.js
new file mode 100644 (file)
index 0000000..4068bea
--- /dev/null
@@ -0,0 +1,37 @@
+import OpenSeadragon from 'openseadragon';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { createPortal } from 'react-dom';
+
+import { useOpenSeadragon } from './OpenSeadragon';
+
+const Overlay = ({ children, onClick, x, y }) => {
+       const { viewer } = useOpenSeadragon();
+       const [element] = React.useState(document.createElement('div'));
+
+       React.useEffect(() => {
+               if (!viewer) return;
+               viewer.addOverlay(
+                       element,
+                       new OpenSeadragon.Point(x, y),
+                       OpenSeadragon.Placement.CENTER,
+               );
+               if (onClick) {
+                       new OpenSeadragon.MouseTracker({
+                               element,
+                               clickHandler: onClick,
+                       });
+               }
+       }, [onClick, viewer, x, y]);
+
+       return createPortal(children, element);
+};
+
+Overlay.propTypes = {
+       children: PropTypes.node,
+       onClick: PropTypes.func,
+       x: PropTypes.number,
+       y: PropTypes.number,
+};
+
+export default Overlay;
diff --git a/resources/js/components/map/Pin.js b/resources/js/components/map/Pin.js
new file mode 100644 (file)
index 0000000..69b6d77
--- /dev/null
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+
+import Overlay from './Overlay';
+import Popover from './Popover';
+import Icon from '../common/Icon';
+import { getLink, getTranslation } from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Pin = ({ pin }) => {
+       const [showPopover, setShowPopover] = React.useState(false);
+       const ref = React.useRef();
+
+       const navigate = useNavigate();
+
+       const onClick = React.useCallback((e) => {
+               if (ref.current && ref.current.contains(e.originalTarget)) {
+                       if (e.originalTarget.tagName === 'A') {
+                               navigate(new URL(e.originalTarget.href).pathname);
+                       }
+               } else {
+                       if (pin.technique.type === 'location') {
+                               setShowPopover(s => !s);
+                       } else {
+                               navigate(getLink(pin.technique));
+                       }
+               }
+       }, [pin]);
+
+       return <Overlay onClick={onClick} x={pin.x} y={pin.y}>
+               <div className="map-pin">
+                       <Link to={getLink(pin.technique)}>
+                               <Icon.PIN title={getTranslation(pin.technique, 'title', i18n.language)} />
+                       </Link>
+                       {pin.technique.type === 'location' ?
+                               <div ref={ref}>
+                                       <Popover show={showPopover} technique={pin.technique} />
+                               </div>
+                       : null}
+               </div>
+       </Overlay>;
+};
+
+Pin.propTypes = {
+       pin: PropTypes.shape({
+               technique: PropTypes.shape({
+                       type: PropTypes.string,
+               }),
+               x: PropTypes.number,
+               y: PropTypes.number,
+       }),
+};
+
+export default Pin;
diff --git a/resources/js/components/map/Pins.js b/resources/js/components/map/Pins.js
new file mode 100644 (file)
index 0000000..8b37ee9
--- /dev/null
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { useOpenSeadragon } from './OpenSeadragon';
+import Pin from './Pin';
+
+const Pins = () => {
+       const { pins } = useOpenSeadragon();
+
+       return pins.map(pin =>
+               <Pin key={pin.id} pin={pin} />
+       );
+};
+
+export default Pins;
diff --git a/resources/js/components/map/Popover.js b/resources/js/components/map/Popover.js
new file mode 100644 (file)
index 0000000..02d4070
--- /dev/null
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Card, ListGroup } from 'react-bootstrap';
+import { Link } from 'react-router-dom';
+
+import {
+       getLink,
+       getRelations,
+       getTranslation,
+       hasRelations,
+       sorted,
+} from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Popover = ({ show, technique }) =>
+       <div className={`map-popover ${show ? 'shown' : 'hidden'}`}>
+               <Card bg="dark">
+                       <Card.Header>
+                               <Card.Title>
+                                       {getTranslation(technique, 'title', i18n.language)}
+                               </Card.Title>
+                       </Card.Header>
+                       {technique.short ?
+                               <Card.Body>
+                                       <Card.Text>
+                                               {getTranslation(technique, 'short', i18n.language)}
+                                       </Card.Text>
+                               </Card.Body>
+                       : null}
+                       {hasRelations(technique, 'related') ?
+                               <ListGroup variant="flush">
+                                       {sorted(getRelations(technique, 'related')).map(r =>
+                                               <ListGroup.Item
+                                                       key={r.id}
+                                                       title={getTranslation(r, 'short', i18n.language)}
+                                               >
+                                                       <Link to={getLink(r)}>
+                                                               {getTranslation(r, 'title', i18n.language)}
+                                                       </Link>
+                                               </ListGroup.Item>
+                                       )}
+                               </ListGroup>
+                       : null}
+               </Card>
+       </div>;
+
+Popover.propTypes = {
+       show: PropTypes.bool,
+       technique: PropTypes.shape({
+               short: PropTypes.string,
+       }),
+};
+
+export default Popover;
index 50a28fbfe1bf89a731addb2607f34bc5b2197c55..d8a10393d12a0c70292ceb4307e682594c4c4569 100644 (file)
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
 
 import Buttons from '../map/Buttons';
 import OpenSeadragon from '../map/OpenSeadragon';
+import Pins from '../map/Pins';
 
 const Map = () => {
        const container = React.useRef();
@@ -16,6 +17,7 @@ const Map = () => {
                                <Buttons />
                        </div>
                        <div ref={container} style={{ height: '80vh' }} />
+                       <Pins />
                </OpenSeadragon>
        </Container>;
 };
index d0506074f5c4b0acd9a61480740df6f95fd97624..90af3584921c311fddd0eef35ce86500b963a17f 100644 (file)
@@ -15,6 +15,7 @@
 @import 'discord';
 @import 'form';
 @import 'front';
+@import 'map';
 @import 'participants';
 @import 'results';
 @import 'rounds';
diff --git a/resources/sass/map.scss b/resources/sass/map.scss
new file mode 100644 (file)
index 0000000..95d645a
--- /dev/null
@@ -0,0 +1,18 @@
+.map-pin {
+       path {
+               fill: red;
+               stroke: black;
+               stroke-width: 2px;
+               vector-effect: non-scaling-stroke;
+       }
+}
+
+.map-popover {
+       position: absolute;
+       width: 40vw;
+       min-width: 20em;
+
+       &.hidden {
+               display: none;
+       }
+}
index 4ec1bb1eab8bbec2e4a108d2235fa7a9e7613d25..4c1d8abe05cc65f71fe1029903bc99c799485ca4 100644 (file)
@@ -36,6 +36,8 @@ Route::get('discord-guilds', 'App\Http\Controllers\DiscordGuildController@search
 Route::get('discord-guilds/{guild_id}', 'App\Http\Controllers\DiscordGuildController@single');
 Route::get('discord-guilds/{guild_id}/channels', 'App\Http\Controllers\DiscordChannelController@search');
 
+Route::get('markers/{map}', 'App\Http\Controllers\TechniqueController@forMap');
+
 Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament');
 
 Route::post('results', 'App\Http\Controllers\ResultController@create');