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',
--- /dev/null
+<?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',
+ ];
+
+}
--- /dev/null
+<?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');
+ }
+};
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');
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>;
};
+import axios from 'axios';
import OpenSeadragon from 'openseadragon';
import PropTypes from 'prop-types';
import React from 'react';
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(() => {
};
}, [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>;
});
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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;
import Buttons from '../map/Buttons';
import OpenSeadragon from '../map/OpenSeadragon';
+import Pins from '../map/Pins';
const Map = () => {
const container = React.useRef();
<Buttons />
</div>
<div ref={container} style={{ height: '80vh' }} />
+ <Pins />
</OpenSeadragon>
</Container>;
};
@import 'discord';
@import 'form';
@import 'front';
+@import 'map';
@import 'participants';
@import 'results';
@import 'rounds';
--- /dev/null
+.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;
+ }
+}
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');