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 = {
|
||||
root: true,
|
||||
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/typescript/recommended'
|
||||
],
|
||||
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
|
||||
rules: {
|
||||
'no-console': 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": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -13,15 +15,24 @@
|
|||
"vue-class-component": "^8.0.0-0"
|
||||
},
|
||||
"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/parser": "^6.5.0",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~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/eslint-config-typescript": "^11.0.3",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"axios": "^1.5.0",
|
||||
"bulma": "^0.9.4",
|
||||
"chai": "^4.2.0",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"sass": "^1.66.1",
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
</h1>
|
||||
<h2 class="subtitle has-text-centered">Der Gelöt</h2>
|
||||
|
||||
<DoorMapEditor />
|
||||
|
||||
<CalendarDoor
|
||||
v-for="(_, index) in 24"
|
||||
:key="index"
|
||||
|
@ -29,12 +31,14 @@
|
|||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
import CalendarDoor from "./components/CalendarDoor.vue";
|
||||
import DoorMapEditor from "./components/DoorMapEditor.vue";
|
||||
import ImageModal from "./components/ImageModal.vue";
|
||||
import LoginModal from "./components/LoginModal.vue";
|
||||
|
||||
@Options({
|
||||
components: {
|
||||
CalendarDoor,
|
||||
DoorMapEditor,
|
||||
ImageModal,
|
||||
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 { FontAwesomePlugin } from "@/plugins/fontawesome"
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
|
@ -6,5 +7,6 @@ import "@/main.scss"
|
|||
|
||||
const app = createApp(App)
|
||||
app.use(Advent22Plugin)
|
||||
app.use(FontAwesomePlugin)
|
||||
|
||||
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,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
"webpack-env",
|
||||
"mocha",
|
||||
"chai"
|
||||
],
|
||||
"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