Merge branch 'feature/drawrects' into develop
This commit is contained in:
commit
e097e3011e
19 changed files with 1898 additions and 59 deletions
|
@ -1,18 +1,34 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
node: true
|
node: true
|
||||||
},
|
},
|
||||||
|
|
||||||
'extends': [
|
'extends': [
|
||||||
'plugin:vue/vue3-essential',
|
'plugin:vue/vue3-essential',
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'@vue/typescript/recommended'
|
'@vue/typescript/recommended'
|
||||||
],
|
],
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 2020
|
ecmaVersion: 2020
|
||||||
},
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
|
||||||
|
},
|
||||||
|
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'**/__tests__/*.{j,t}s?(x)',
|
||||||
|
'**/tests/unit/**/*.spec.{j,t}s?(x)'
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
mocha: true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
"build": "vue-cli-service build",
|
"build": "vue-cli-service build",
|
||||||
|
"test:unit": "vue-cli-service test:unit",
|
||||||
|
"test:unit-watch": "vue-cli-service test:unit --watch",
|
||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -13,15 +15,24 @@
|
||||||
"vue-class-component": "^8.0.0-0"
|
"vue-class-component": "^8.0.0-0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.0.2",
|
||||||
|
"@types/chai": "^4.3.5",
|
||||||
|
"@types/mocha": "^10.0.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||||
"@typescript-eslint/parser": "^6.5.0",
|
"@typescript-eslint/parser": "^6.5.0",
|
||||||
"@vue/cli-plugin-babel": "~5.0.0",
|
"@vue/cli-plugin-babel": "~5.0.0",
|
||||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-unit-mocha": "~5.0.0",
|
||||||
"@vue/cli-service": "~5.0.0",
|
"@vue/cli-service": "~5.0.0",
|
||||||
"@vue/eslint-config-typescript": "^11.0.3",
|
"@vue/eslint-config-typescript": "^11.0.3",
|
||||||
|
"@vue/test-utils": "^2.4.1",
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.5.0",
|
||||||
"bulma": "^0.9.4",
|
"bulma": "^0.9.4",
|
||||||
|
"chai": "^4.2.0",
|
||||||
"eslint": "^8.48.0",
|
"eslint": "^8.48.0",
|
||||||
"eslint-plugin-vue": "^9.17.0",
|
"eslint-plugin-vue": "^9.17.0",
|
||||||
"sass": "^1.66.1",
|
"sass": "^1.66.1",
|
||||||
|
|
|
@ -9,6 +9,8 @@
|
||||||
</h1>
|
</h1>
|
||||||
<h2 class="subtitle has-text-centered">Der Gelöt</h2>
|
<h2 class="subtitle has-text-centered">Der Gelöt</h2>
|
||||||
|
|
||||||
|
<DoorMapEditor />
|
||||||
|
|
||||||
<CalendarDoor
|
<CalendarDoor
|
||||||
v-for="(_, index) in 24"
|
v-for="(_, index) in 24"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
@ -29,12 +31,14 @@
|
||||||
import { Options, Vue } from "vue-class-component";
|
import { Options, Vue } from "vue-class-component";
|
||||||
|
|
||||||
import CalendarDoor from "./components/CalendarDoor.vue";
|
import CalendarDoor from "./components/CalendarDoor.vue";
|
||||||
|
import DoorMapEditor from "./components/DoorMapEditor.vue";
|
||||||
import ImageModal from "./components/ImageModal.vue";
|
import ImageModal from "./components/ImageModal.vue";
|
||||||
import LoginModal from "./components/LoginModal.vue";
|
import LoginModal from "./components/LoginModal.vue";
|
||||||
|
|
||||||
@Options({
|
@Options({
|
||||||
components: {
|
components: {
|
||||||
CalendarDoor,
|
CalendarDoor,
|
||||||
|
DoorMapEditor,
|
||||||
ImageModal,
|
ImageModal,
|
||||||
LoginModal,
|
LoginModal,
|
||||||
},
|
},
|
||||||
|
|
BIN
ui/src/assets/adventskalender.jpg
(Stored with Git LFS)
Normal file
BIN
ui/src/assets/adventskalender.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
48
ui/src/components/BulmaBreadcrumbs.vue
Normal file
48
ui/src/components/BulmaBreadcrumbs.vue
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<nav class="breadcrumb has-succeeds-separator">
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="`step-${index}`"
|
||||||
|
:class="modelValue === index ? 'is-active' : ''"
|
||||||
|
@click="change_step(index)"
|
||||||
|
>
|
||||||
|
<a>
|
||||||
|
<span class="icon is-small">
|
||||||
|
<font-awesome-icon :icon="step.icon" />
|
||||||
|
</span>
|
||||||
|
<span>{{ step.label }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Options, Vue } from "vue-class-component";
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
props: {
|
||||||
|
steps: Array,
|
||||||
|
modelValue: Number,
|
||||||
|
},
|
||||||
|
emits: ["update:modelValue"],
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private steps!: Step[];
|
||||||
|
private modelValue!: number;
|
||||||
|
|
||||||
|
private change_step(next_step: number) {
|
||||||
|
if (next_step === this.modelValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit("update:modelValue", next_step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
37
ui/src/components/DoorMapEditor.vue
Normal file
37
ui/src/components/DoorMapEditor.vue
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<template>
|
||||||
|
<div class="box">
|
||||||
|
<p class="title is-4">Türchen bearbeiten</p>
|
||||||
|
|
||||||
|
<BulmaBreadcrumbs :steps="steps" v-model="current_step" />
|
||||||
|
|
||||||
|
<DoorPlacer v-if="current_step === 0" v-model:doors="doors" />
|
||||||
|
<DoorChooser v-if="current_step === 1" v-model:doors="doors" />
|
||||||
|
<p v-if="current_step === 2">Bar</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Options } from "vue-class-component";
|
||||||
|
import { Door } from "./door_map/calendar";
|
||||||
|
|
||||||
|
import BulmaBreadcrumbs, { Step } from "./BulmaBreadcrumbs.vue";
|
||||||
|
import DoorPlacer from "./door_map/DoorPlacer.vue";
|
||||||
|
import DoorChooser from "./door_map/DoorChooser.vue";
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
components: {
|
||||||
|
BulmaBreadcrumbs,
|
||||||
|
DoorPlacer,
|
||||||
|
DoorChooser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private readonly steps: Step[] = [
|
||||||
|
{ label: "Platzieren", icon: "fa-solid fa-crosshairs" },
|
||||||
|
{ label: "Ordnen", icon: "fa-solid fa-list-ol" },
|
||||||
|
{ label: "Überprüfen", icon: "fa-solid fa-check" },
|
||||||
|
];
|
||||||
|
private current_step = 0;
|
||||||
|
private doors: Door[] = [];
|
||||||
|
}
|
||||||
|
</script>
|
56
ui/src/components/door_map/DoorChooser.vue
Normal file
56
ui/src/components/door_map/DoorChooser.vue
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="content">
|
||||||
|
<p class="title is-5">Steuerung</p>
|
||||||
|
<ul>
|
||||||
|
<li>Linksklick: Türchen bearbeiten</li>
|
||||||
|
<li>Tastatur: Tag eingeben</li>
|
||||||
|
<li>[Enter]: Tag speichern</li>
|
||||||
|
<li>[Esc]: Eingabe Abbrechen</li>
|
||||||
|
<li>[Entf]: Tag entfernen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<figure class="image">
|
||||||
|
<img src="@/assets/adventskalender.jpg" />
|
||||||
|
<ThouCanvas>
|
||||||
|
<PreviewDoor
|
||||||
|
v-for="(_, index) in doors"
|
||||||
|
:key="`door-${index}`"
|
||||||
|
v-model:door="doors[index]"
|
||||||
|
/>
|
||||||
|
</ThouCanvas>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Options, Vue } from "vue-class-component";
|
||||||
|
import { Door } from "./calendar";
|
||||||
|
|
||||||
|
import ThouCanvas from "../rects/ThouCanvas.vue";
|
||||||
|
import PreviewDoor from "./PreviewDoor.vue";
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
components: {
|
||||||
|
ThouCanvas,
|
||||||
|
PreviewDoor,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
doors: Array,
|
||||||
|
},
|
||||||
|
emits: ["update:doors"],
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private doors!: Door[];
|
||||||
|
|
||||||
|
public beforeUnmount() {
|
||||||
|
this.$emit("update:doors", this.doors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
section > figure {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
83
ui/src/components/door_map/DoorPlacer.vue
Normal file
83
ui/src/components/door_map/DoorPlacer.vue
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="content">
|
||||||
|
<p class="title is-5">Steuerung</p>
|
||||||
|
<ul>
|
||||||
|
<li>Linksklick + Ziehen: Neues Türchen erstellen</li>
|
||||||
|
<li>Rechtsklick + Ziehen: Türchen verschieben</li>
|
||||||
|
<li>Doppel- oder Mittelklick: Türchen löschen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<figure class="image">
|
||||||
|
<img src="@/assets/adventskalender.jpg" />
|
||||||
|
<RectangleCanvas
|
||||||
|
:rectangles="rectangles"
|
||||||
|
@draw="on_draw"
|
||||||
|
@drag="on_drag"
|
||||||
|
@remove="on_remove"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Options } from "vue-class-component";
|
||||||
|
import { Rectangle } from "../rects/rectangles";
|
||||||
|
import { Door } from "./calendar";
|
||||||
|
import RectangleCanvas from "./RectangleCanvas.vue";
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
components: {
|
||||||
|
RectangleCanvas,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
doors: Array,
|
||||||
|
},
|
||||||
|
emits: ["update:doors"],
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private doors!: Door[];
|
||||||
|
|
||||||
|
private get rectangles() {
|
||||||
|
return this.doors.map((door) => door.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private on_draw(position: Rectangle) {
|
||||||
|
this.doors.push(new Door(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
private find_door_index(position: Rectangle): number {
|
||||||
|
return this.doors.findIndex((door) => door.position.equals(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
private on_drag(old_pos: Rectangle, new_pos: Rectangle) {
|
||||||
|
const idx = this.find_door_index(old_pos);
|
||||||
|
|
||||||
|
if (idx === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.doors[idx].position = new_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
private on_remove(position: Rectangle) {
|
||||||
|
const idx = this.find_door_index(position);
|
||||||
|
|
||||||
|
if (idx === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.doors.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public beforeUnmount() {
|
||||||
|
this.$emit("update:doors", this.doors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
section > figure {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
155
ui/src/components/door_map/PreviewDoor.vue
Normal file
155
ui/src/components/door_map/PreviewDoor.vue
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
<template>
|
||||||
|
<SVGRect :rectangle="door.position" :focused="editing" />
|
||||||
|
<foreignObject
|
||||||
|
:x="Math.round(parent_aspect_ratio * door.position.left)"
|
||||||
|
:y="door.position.top"
|
||||||
|
:width="Math.round(parent_aspect_ratio * door.position.width)"
|
||||||
|
:height="door.position.height"
|
||||||
|
:style="`transform: scaleX(${1 / parent_aspect_ratio})`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
xmlns="http://www.w3.org/1999/xhtml"
|
||||||
|
class="is-flex is-align-items-center is-justify-content-center"
|
||||||
|
@click.left="on_click"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-if="editing"
|
||||||
|
v-model="day_str"
|
||||||
|
ref="day_input"
|
||||||
|
class="input is-large"
|
||||||
|
type="number"
|
||||||
|
min="-1"
|
||||||
|
placeholder="Tag"
|
||||||
|
@keydown="on_keydown"
|
||||||
|
/>
|
||||||
|
<div v-else class="is-size-1 has-text-weight-bold">
|
||||||
|
<template v-if="door.day >= 0">{{ door.day }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Options, Vue } from "vue-class-component";
|
||||||
|
import { Door } from "./calendar";
|
||||||
|
|
||||||
|
import SVGRect from "../rects/SVGRect.vue";
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
components: {
|
||||||
|
SVGRect,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
door: Door,
|
||||||
|
editable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ["update:door"],
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private door!: Door;
|
||||||
|
private editable!: boolean;
|
||||||
|
|
||||||
|
private day_str = "";
|
||||||
|
private editing = false;
|
||||||
|
private refreshKey = 0;
|
||||||
|
|
||||||
|
declare $refs: {
|
||||||
|
day_input: HTMLInputElement | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
private refresh() {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
// don't loop endlessly
|
||||||
|
if (this.refreshKey < 10000) {
|
||||||
|
this.refreshKey++;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get parent_aspect_ratio(): number {
|
||||||
|
this.refreshKey; // read it just to force recompute on change
|
||||||
|
|
||||||
|
if (!(this.$el instanceof Text) || this.$el.parentElement === null) {
|
||||||
|
this.refresh();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = this.$el.parentElement;
|
||||||
|
const result = parent.clientWidth / parent.clientHeight;
|
||||||
|
|
||||||
|
// force recompute for suspicious results
|
||||||
|
if (result === 0 || result === Infinity) {
|
||||||
|
this.refresh();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get day_num(): number {
|
||||||
|
const result = Number(this.day_str);
|
||||||
|
return isNaN(result) ? -1 : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggle_editing() {
|
||||||
|
this.day_str = String(this.door.day);
|
||||||
|
this.editing = this.editable && !this.editing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private on_click(event: MouseEvent) {
|
||||||
|
if (event.target === null || !(event.target instanceof HTMLDivElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.editing) {
|
||||||
|
const day_input_focus = () => {
|
||||||
|
if (this.$refs.day_input instanceof HTMLInputElement) {
|
||||||
|
this.$refs.day_input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(day_input_focus);
|
||||||
|
};
|
||||||
|
day_input_focus();
|
||||||
|
} else {
|
||||||
|
this.door.day = this.day_num;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toggle_editing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private on_keydown(event: KeyboardEvent) {
|
||||||
|
if (!this.editing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
this.door.day = this.day_num;
|
||||||
|
this.toggle_editing();
|
||||||
|
} else if (event.key === "Delete") {
|
||||||
|
this.door.day = -1;
|
||||||
|
this.toggle_editing();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
this.toggle_editing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public beforeUnmount() {
|
||||||
|
this.$emit("update:door", this.door);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
foreignObject {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
color: red;
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
151
ui/src/components/door_map/RectangleCanvas.vue
Normal file
151
ui/src/components/door_map/RectangleCanvas.vue
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
<template>
|
||||||
|
<ThouCanvas
|
||||||
|
@mousedown.left="draw_start"
|
||||||
|
@mouseup.left="draw_finish"
|
||||||
|
@mousedown.right="drag_start"
|
||||||
|
@mouseup.right="drag_finish"
|
||||||
|
@mousemove="on_mousemove"
|
||||||
|
@click.middle="remove_rect"
|
||||||
|
@dblclick.left="remove_rect"
|
||||||
|
>
|
||||||
|
<SVGRect
|
||||||
|
v-for="(rect, index) in rectangles"
|
||||||
|
:key="`rect-${index}`"
|
||||||
|
:rectangle="rect"
|
||||||
|
/>
|
||||||
|
<SVGRect v-if="preview_visible" :focused="true" :rectangle="preview_rect" />
|
||||||
|
</ThouCanvas>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Options } from "vue-class-component";
|
||||||
|
import { Vector2D, Rectangle } from "../rects/rectangles";
|
||||||
|
import ThouCanvas from "../rects/ThouCanvas.vue";
|
||||||
|
import SVGRect from "../rects/SVGRect.vue";
|
||||||
|
|
||||||
|
enum CanvasState {
|
||||||
|
Idle,
|
||||||
|
Drawing,
|
||||||
|
Dragging,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
components: {
|
||||||
|
ThouCanvas,
|
||||||
|
SVGRect,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
rectangles: Array,
|
||||||
|
},
|
||||||
|
emits: ["draw", "drag", "remove"],
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private readonly min_rect_area = 300;
|
||||||
|
private state = CanvasState.Idle;
|
||||||
|
private preview_rect = new Rectangle();
|
||||||
|
private drag_rect?: Rectangle;
|
||||||
|
private drag_origin = new Vector2D();
|
||||||
|
private rectangles!: Rectangle[];
|
||||||
|
|
||||||
|
private get preview_visible(): boolean {
|
||||||
|
return this.state !== CanvasState.Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pop_rectangle(point: Vector2D): Rectangle | undefined {
|
||||||
|
const idx = this.rectangles.findIndex((rect) => rect.contains(point));
|
||||||
|
|
||||||
|
if (idx === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.rectangles.splice(idx, 1)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw_start(event: MouseEvent, point: Vector2D) {
|
||||||
|
if (this.preview_visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = CanvasState.Drawing;
|
||||||
|
this.preview_rect = new Rectangle(point, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw_finish() {
|
||||||
|
if (this.state !== CanvasState.Drawing || this.preview_rect === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = CanvasState.Idle;
|
||||||
|
|
||||||
|
if (this.preview_rect.area < this.min_rect_area) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rectangles.push(this.preview_rect);
|
||||||
|
this.$emit("draw", this.preview_rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drag_start(event: MouseEvent, point: Vector2D) {
|
||||||
|
if (this.preview_visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drag_rect = this.pop_rectangle(point);
|
||||||
|
|
||||||
|
if (this.drag_rect === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = CanvasState.Dragging;
|
||||||
|
this.drag_origin = point;
|
||||||
|
|
||||||
|
this.preview_rect = this.drag_rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
private drag_finish() {
|
||||||
|
if (
|
||||||
|
this.state !== CanvasState.Dragging ||
|
||||||
|
this.preview_rect === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = CanvasState.Idle;
|
||||||
|
this.rectangles.push(this.preview_rect);
|
||||||
|
this.$emit("drag", this.drag_rect, this.preview_rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private on_mousemove(event: MouseEvent, point: Vector2D) {
|
||||||
|
if (this.preview_rect === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state === CanvasState.Drawing) {
|
||||||
|
this.preview_rect = this.preview_rect.update(undefined, point);
|
||||||
|
} else if (this.state === CanvasState.Dragging && this.drag_rect) {
|
||||||
|
const movement = point.minus(this.drag_origin);
|
||||||
|
this.preview_rect = this.drag_rect.move(movement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove_rect(event: MouseEvent, point: Vector2D) {
|
||||||
|
if (this.preview_visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = this.pop_rectangle(point);
|
||||||
|
|
||||||
|
if (rect === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit("remove", rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
* {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
</style>
|
19
ui/src/components/door_map/calendar.ts
Normal file
19
ui/src/components/door_map/calendar.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Rectangle } from "../rects/rectangles";
|
||||||
|
|
||||||
|
export class Door {
|
||||||
|
private _day = -1;
|
||||||
|
public position: Rectangle;
|
||||||
|
|
||||||
|
constructor(position: Rectangle, day = -1) {
|
||||||
|
this.day = day;
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get day(): number {
|
||||||
|
return this._day
|
||||||
|
}
|
||||||
|
|
||||||
|
public set day(day: number) {
|
||||||
|
this._day = Math.max(day, -1);
|
||||||
|
}
|
||||||
|
}
|
43
ui/src/components/rects/SVGRect.vue
Normal file
43
ui/src/components/rects/SVGRect.vue
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<rect
|
||||||
|
:class="focused ? 'focus' : ''"
|
||||||
|
:x="rectangle.left"
|
||||||
|
:y="rectangle.top"
|
||||||
|
:width="rectangle.width"
|
||||||
|
:height="rectangle.height"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Options } from "vue-class-component";
|
||||||
|
import { Rectangle } from "./rectangles";
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
props: {
|
||||||
|
focused: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
rectangle: Rectangle,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private focused!: boolean;
|
||||||
|
private rectangle!: Rectangle;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
rect {
|
||||||
|
fill: lightgreen;
|
||||||
|
stroke: green;
|
||||||
|
fill-opacity: 0.2;
|
||||||
|
stroke-opacity: 0.9;
|
||||||
|
stroke-width: 1;
|
||||||
|
|
||||||
|
&.focus {
|
||||||
|
fill: gold;
|
||||||
|
stroke: yellow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
77
ui/src/components/rects/ThouCanvas.vue
Normal file
77
ui/src/components/rects/ThouCanvas.vue
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 1000 1000"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
@contextmenu.prevent
|
||||||
|
@mousedown="transform_mouse_event"
|
||||||
|
@mousemove="transform_mouse_event"
|
||||||
|
@mouseup="transform_mouse_event"
|
||||||
|
@click="transform_mouse_event"
|
||||||
|
@dblclick="transform_mouse_event"
|
||||||
|
>
|
||||||
|
<slot name="default" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Options } from "vue-class-component";
|
||||||
|
import { Vector2D } from "./rectangles";
|
||||||
|
|
||||||
|
function get_event_thous(event: MouseEvent): Vector2D {
|
||||||
|
if (event.currentTarget === null) {
|
||||||
|
return new Vector2D();
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.currentTarget as Element;
|
||||||
|
|
||||||
|
return new Vector2D(
|
||||||
|
Math.round((event.offsetX / target.clientWidth) * 1000),
|
||||||
|
Math.round((event.offsetY / target.clientHeight) * 1000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouse_event_validator(event: object, point: object): boolean {
|
||||||
|
if (!(event instanceof MouseEvent)) {
|
||||||
|
console.warn(event, "is not a MouseEvent!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(point instanceof Vector2D)) {
|
||||||
|
console.warn(point, "is not a Vector2D!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
emits: {
|
||||||
|
mousedown: mouse_event_validator,
|
||||||
|
mouseup: mouse_event_validator,
|
||||||
|
mousemove: mouse_event_validator,
|
||||||
|
click: mouse_event_validator,
|
||||||
|
dblclick: mouse_event_validator,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class extends Vue {
|
||||||
|
private transform_mouse_event(event: MouseEvent) {
|
||||||
|
const point = get_event_thous(event);
|
||||||
|
this.$emit(event.type, event, point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
z-index: auto;
|
||||||
|
}
|
||||||
|
</style>
|
104
ui/src/components/rects/rectangles.ts
Normal file
104
ui/src/components/rects/rectangles.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
export class Vector2D {
|
||||||
|
public readonly x: number;
|
||||||
|
public readonly y: number;
|
||||||
|
|
||||||
|
constructor(x = 0, y = 0) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public plus(other: Vector2D): Vector2D {
|
||||||
|
return new Vector2D(this.x + other.x, this.y + other.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public minus(other: Vector2D): Vector2D {
|
||||||
|
return new Vector2D(this.x - other.x, this.y - other.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public scale(other: number): Vector2D {
|
||||||
|
return new Vector2D(this.x * other, this.y * other);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(other: Vector2D): boolean {
|
||||||
|
return this.x === other.x &&
|
||||||
|
this.y === other.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Rectangle {
|
||||||
|
private readonly corner_1: Vector2D;
|
||||||
|
private readonly corner_2: Vector2D;
|
||||||
|
|
||||||
|
constructor(corner_1 = new Vector2D(), corner_2 = new Vector2D()) {
|
||||||
|
this.corner_1 = corner_1;
|
||||||
|
this.corner_2 = corner_2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get origin(): Vector2D {
|
||||||
|
return new Vector2D(
|
||||||
|
Math.min(this.corner_1.x, this.corner_2.x),
|
||||||
|
Math.min(this.corner_1.y, this.corner_2.y),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get left(): number {
|
||||||
|
return this.origin.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get top(): number {
|
||||||
|
return this.origin.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get corner(): Vector2D {
|
||||||
|
return new Vector2D(
|
||||||
|
Math.max(this.corner_1.x, this.corner_2.x),
|
||||||
|
Math.max(this.corner_1.y, this.corner_2.y),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get size(): Vector2D {
|
||||||
|
return this.corner.minus(this.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get width(): number {
|
||||||
|
return this.size.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get height(): number {
|
||||||
|
return this.size.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get middle(): Vector2D {
|
||||||
|
return this.origin.plus(this.size.scale(0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
public get area(): number {
|
||||||
|
return this.width * this.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(other: Rectangle): boolean {
|
||||||
|
return this.origin.equals(other.origin) &&
|
||||||
|
this.corner.equals(other.corner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(point: Vector2D): boolean {
|
||||||
|
return point.x >= this.origin.x &&
|
||||||
|
point.y >= this.origin.y &&
|
||||||
|
point.x <= this.corner.x &&
|
||||||
|
point.y <= this.corner.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(corner_1?: Vector2D, corner_2?: Vector2D): Rectangle {
|
||||||
|
return new Rectangle(
|
||||||
|
corner_1 || this.corner_1,
|
||||||
|
corner_2 || this.corner_2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public move(vector: Vector2D): Rectangle {
|
||||||
|
return new Rectangle(
|
||||||
|
this.corner_1.plus(vector),
|
||||||
|
this.corner_2.plus(vector),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { Advent22Plugin } from "@/plugins/advent22"
|
import { Advent22Plugin } from "@/plugins/advent22"
|
||||||
|
import { FontAwesomePlugin } from "@/plugins/fontawesome"
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
|
@ -6,5 +7,6 @@ import "@/main.scss"
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(Advent22Plugin)
|
app.use(Advent22Plugin)
|
||||||
|
app.use(FontAwesomePlugin)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
20
ui/src/plugins/fontawesome.ts
Normal file
20
ui/src/plugins/fontawesome.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { App, Plugin } from 'vue';
|
||||||
|
|
||||||
|
/* import the fontawesome core */
|
||||||
|
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||||
|
|
||||||
|
/* import specific icons */
|
||||||
|
import { fab } from '@fortawesome/free-brands-svg-icons';
|
||||||
|
import { fas } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
/* add icons to the library */
|
||||||
|
library.add(fas, fab);
|
||||||
|
|
||||||
|
/* import font awesome icon component */
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||||
|
|
||||||
|
export const FontAwesomePlugin: Plugin = {
|
||||||
|
install(app: App) {
|
||||||
|
app.component('font-awesome-icon', FontAwesomeIcon);
|
||||||
|
}
|
||||||
|
}
|
126
ui/tests/unit/rectangle.spec.ts
Normal file
126
ui/tests/unit/rectangle.spec.ts
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import { Rectangle, Vector2D } from '@/components/rects/rectangles';
|
||||||
|
import { expect } from "chai";
|
||||||
|
|
||||||
|
describe("Vector2D Tests", () => {
|
||||||
|
const v = new Vector2D(1, 2);
|
||||||
|
|
||||||
|
it("should create a default vector", () => {
|
||||||
|
const v0 = new Vector2D();
|
||||||
|
expect(v0.x).to.equal(0);
|
||||||
|
expect(v0.y).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a vector", () => {
|
||||||
|
expect(v.x).to.equal(1);
|
||||||
|
expect(v.y).to.equal(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add vectors", () => {
|
||||||
|
const v2 = v.plus(new Vector2D(3, 4));
|
||||||
|
expect(v2.x).to.equal(4);
|
||||||
|
expect(v2.y).to.equal(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should subtract vectors", () => {
|
||||||
|
const v2 = v.minus(new Vector2D(3, 4));
|
||||||
|
expect(v2.x).to.equal(-2);
|
||||||
|
expect(v2.y).to.equal(-2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should scale vectors", () => {
|
||||||
|
const v2 = v.scale(3);
|
||||||
|
expect(v2.x).to.equal(3);
|
||||||
|
expect(v2.y).to.equal(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compare vectors", () => {
|
||||||
|
expect(v.equals(v.scale(1))).to.be.true;
|
||||||
|
expect(v.equals(v.scale(2))).to.be.false;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rectangle Tests", () => {
|
||||||
|
const v1 = new Vector2D(1, 2);
|
||||||
|
const v2 = new Vector2D(4, 6);
|
||||||
|
|
||||||
|
|
||||||
|
const r1 = new Rectangle(v1, v2);
|
||||||
|
const r2 = new Rectangle(v2, v1);
|
||||||
|
|
||||||
|
function check_rectangle(
|
||||||
|
r: Rectangle,
|
||||||
|
left: number, top: number,
|
||||||
|
width: number, height: number,
|
||||||
|
) {
|
||||||
|
expect(r.left).to.equal(left);
|
||||||
|
expect(r.top).to.equal(top);
|
||||||
|
|
||||||
|
expect(r.width).to.equal(width);
|
||||||
|
expect(r.height).to.equal(height);
|
||||||
|
expect(r.area).to.equal(width * height);
|
||||||
|
|
||||||
|
expect(r.middle.x).to.equal(left + 0.5 * width);
|
||||||
|
expect(r.middle.y).to.equal(top + 0.5 * height);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should create a default rectangle", () => {
|
||||||
|
check_rectangle(new Rectangle(), 0, 0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a rectangle", () => {
|
||||||
|
check_rectangle(r1, 1, 2, 3, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create the same rectangle backwards", () => {
|
||||||
|
check_rectangle(r2, 1, 2, 3, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compare rectangles", () => {
|
||||||
|
expect(r1.equals(r2)).to.be.true;
|
||||||
|
expect(r1.equals(new Rectangle())).to.be.false;
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create the same rectangle transposed", () => {
|
||||||
|
const v1t = new Vector2D(v1.x, v2.y);
|
||||||
|
const v2t = new Vector2D(v2.x, v1.y);
|
||||||
|
|
||||||
|
expect(r1.equals(new Rectangle(v1t, v2t))).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should contain itself", () => {
|
||||||
|
expect(r1.contains(v1)).to.be.true;
|
||||||
|
expect(r1.contains(v2)).to.be.true;
|
||||||
|
|
||||||
|
expect(r1.contains(r1.origin)).to.be.true;
|
||||||
|
expect(r1.contains(r1.corner)).to.be.true;
|
||||||
|
expect(r1.contains(r1.middle)).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not contain certain points", () => {
|
||||||
|
expect(r1.contains(new Vector2D(0, 0))).to.be.false;
|
||||||
|
expect(r1.contains(new Vector2D(100, 100))).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update a rectangle", () => {
|
||||||
|
const v = new Vector2D(1, 1);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(v1.plus(v), undefined), 2, 3, 2, 3);
|
||||||
|
check_rectangle(r1.update(v1.minus(v), undefined), 0, 1, 4, 5);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(undefined, v2.plus(v)), 1, 2, 4, 5);
|
||||||
|
check_rectangle(r1.update(undefined, v2.minus(v)), 1, 2, 2, 3);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(v1.plus(v), v2.plus(v)), 2, 3, 3, 4);
|
||||||
|
check_rectangle(r1.update(v1.minus(v), v2.minus(v)), 0, 1, 3, 4);
|
||||||
|
|
||||||
|
check_rectangle(r1.update(v1.minus(v), v2.plus(v)), 0, 1, 5, 6);
|
||||||
|
check_rectangle(r1.update(v1.plus(v), v2.minus(v)), 2, 3, 1, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move a rectangle", () => {
|
||||||
|
const v = new Vector2D(1, 1);
|
||||||
|
|
||||||
|
check_rectangle(r1.move(v), 2, 3, 3, 4);
|
||||||
|
});
|
||||||
|
});
|
|
@ -14,7 +14,9 @@
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"types": [
|
"types": [
|
||||||
"webpack-env"
|
"webpack-env",
|
||||||
|
"mocha",
|
||||||
|
"chai"
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
|
|
996
ui/yarn.lock
996
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue