zoom in on the object on load

This commit is contained in:
Evert Prants 2023-01-20 18:43:32 +02:00
parent 2dc4256f0f
commit eb48192542
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
15 changed files with 313 additions and 65 deletions

View File

@ -70,6 +70,7 @@ import {
RepositionEvent,
ToolEvent,
Vec2,
Vec2Box,
} from '../../modules/house-planner/interfaces';
import type { HousePlannerCanvasTools } from '../../modules/house-planner/tools';
import deepUnref from '../../utils/deep-unref';
@ -98,16 +99,23 @@ const props = withDefaults(
);
const emit = defineEmits<{
(e: 'update', document: FloorDocument, rooms: Line[]): void;
(
e: 'update',
document: FloorDocument,
rooms: Line[],
boundingBox?: Vec2Box
): void;
(e: 'edited'): void;
}>();
const localFloorDocument = ref(deepUnref(props.floorDocument));
const emitUpdate = () =>
module.value.manager &&
emit(
'update',
localFloorDocument.value,
(module.value.manager?.getAllObjectsOfType('room') || []) as Line[]
module.value.manager.getAllObjectsOfType('room') as Line[],
module.value.manager.getBoundingBox()
);
const debouncedUpdate = useDebounceFn(emitUpdate, 10000);
@ -241,14 +249,18 @@ defineExpose({
});
onMounted(() => {
module.value.initialize(
const [setZoom, setPos] = module.value.initialize(
canvas.value,
deepUnref(localFloorDocument.value.layers),
[localFloorDocument.value.width, localFloorDocument.value.height],
canvasPos.value,
canvasZoom.value,
props.editable
props.editable,
localFloorDocument.value.boundingBox
);
canvasPos.value = setPos;
canvasZoom.value = setZoom;
Object.keys(events).forEach((event) =>
canvas.value.addEventListener(event, events[event])
);

View File

@ -0,0 +1,104 @@
<template>
<div class="absolute top-0 left-0 z-10">
<Transition
enter-active-class="transition-max-height ease-out duration-200 overflow-hidden"
enter-from-class="max-h-0"
enter-to-class="max-h-14"
leave-active-class="transition-max-height ease-in duration-150 overflow-hidden"
leave-from-class="max-h-14"
leave-to-class="max-h-0"
>
<div
v-if="open"
class="rounded-br-md border-2 border-t-0 border-gray-200 bg-white shadow-lg"
>
<div class="h-14 px-2 py-2.5">
<div class="flex flex-row items-center space-x-4 px-4">
<RouterLink to="/" title="Go back"
><span class="sr-only">Go back</span
><ChevronLeftIcon class="-ml-4 h-6 w-6"
/></RouterLink>
<div class="flex flex-row items-center space-x-4">
<label for="building">Building:</label>
<select
id="building"
class="rounded-sm border-gray-300 py-1 focus:ring-2 focus:ring-blue-200"
:value="selectedBuildingId"
@change="
emit(
'update:selectedBuildingId',
Number(($event.target as HTMLSelectElement).value)
)
"
>
<option v-for="building of buildings" :value="building.id">
{{ building.displayName }}
</option>
</select>
</div>
<div
class="flex flex-row items-center space-x-4"
v-if="selectedBuildingId"
>
<label for="floor">Floor:</label>
<select
id="floor"
class="rounded-sm border-gray-300 py-1 focus:ring-2 focus:ring-blue-200"
:value="selectedFloorId"
@change="
emit(
'update:selectedFloorId',
Number(($event.target as HTMLSelectElement).value)
)
"
>
<option v-for="floor of floors" :value="floor.id">
{{ floor.displayName }} ({{ floor.number }})
</option>
</select>
</div>
<div v-if="selectedFloorId">{{ status }}</div>
</div>
</div>
</div>
</Transition>
<button
:class="[
'bg-white',
'-mt-0.5 ml-2 h-8 rounded-br-md rounded-bl-md px-2 py-2 ring-1 ring-black ring-opacity-5',
]"
:title="`${open ? 'Hide' : 'Open'} panel`"
:aria-expanded="open"
@click="() => (open = !open)"
>
<span class="sr-only">{{ open ? 'Hide' : 'Open' }} panel</span>
<ChevronDoubleUpIcon class="h-4 w-4" v-if="open" />
<ChevronDoubleDownIcon class="h-4 w-4" v-else />
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
ChevronLeftIcon,
ChevronDoubleDownIcon,
ChevronDoubleUpIcon,
} from '@heroicons/vue/24/outline';
import { BuildingListItem } from '../../interfaces/building.interfaces';
import { FloorListItem } from '../../interfaces/floor.interfaces';
const open = ref(true);
const props = defineProps<{
floors: FloorListItem[];
buildings: BuildingListItem[];
selectedBuildingId?: number;
selectedFloorId?: number;
status: string;
}>();
const emit = defineEmits<{
(e: 'update:selectedBuildingId', buildingId: number): void;
(e: 'update:selectedFloorId', buildingId: number): void;
}>();
</script>

View File

@ -10,7 +10,7 @@
]"
>
<Square3Stack3DIcon class="h-4 w-4" />
<span>{{ layer.name }}</span>
<span>{{ layer.name }} ({{ layer.contents.length }})</span>
</button>
<div class="flex flex-col bg-gray-50" v-if="layer.active">
<div v-for="object of layer.contents">

View File

@ -1,12 +1,15 @@
<template>
<div class="relative flex flex-row px-2 pb-4 pr-0">
<div class="pointer-events-auto relative flex flex-row px-2 pb-4 pr-0">
<button
:class="[
open ? 'bg-gray-200' : 'bg-white',
'h-8 rounded-tl-md rounded-bl-md px-2 py-2 ring-1 ring-black ring-opacity-5',
]"
:title="`${open ? 'Hide' : 'Open'} panel`"
:aria-expanded="open"
@click="() => (open = !open)"
>
<span class="sr-only">{{ open ? 'Hide' : 'Open' }} panel</span>
<ChevronDoubleRightIcon class="h-4 w-4" v-if="open" />
<ChevronDoubleLeftIcon class="h-4 w-4" v-else />
</button>

View File

@ -1,5 +1,7 @@
<template>
<div class="z-8 absolute right-0 top-0 bottom-0 my-4 overflow-hidden">
<div
class="z-8 pointer-events-none absolute right-0 top-0 bottom-0 my-4 overflow-hidden"
>
<div class="flex h-full flex-col items-end space-y-2">
<slot />
</div>

View File

@ -1,4 +1,4 @@
import { Layer } from '../../../modules/house-planner/interfaces';
import { Layer, Vec2 } from '../../../modules/house-planner/interfaces';
export interface FloorDocument {
id: number;
@ -6,4 +6,8 @@ export interface FloorDocument {
width: number;
height: number;
layers: Layer[];
/**
* Min, Max
*/
boundingBox?: [Vec2, Vec2];
}

View File

@ -10,10 +10,13 @@ import {
LineSegment,
RepositionEvent,
Vec2,
Vec2Box,
} from './interfaces';
import { HousePlannerCanvasTools } from './tools';
import { LayerObjectType } from './types';
import {
boundingBox,
isValidVec2,
vec2Add,
vec2AngleFromOrigin,
vec2Distance,
@ -270,6 +273,42 @@ export class HousePlannerCanvas {
return objects;
}
getBoundingBox(): Vec2Box | undefined {
let box: Vec2Box = [
[Infinity, Infinity],
[0, 0],
];
this.layers.forEach((layer) => {
let layerPoints = layer.contents
.filter((object) => ['line', 'curve', 'room'].includes(object.type))
.reduce<Vec2[]>(
(list, object) => [...list, ...extractLinePoints(object as Line)],
[]
);
if (!layerPoints.length) return;
box = boundingBox(layerPoints, box);
});
if (
vec2Distance(box[0], box[1]) < 80 ||
!isValidVec2(box[0]) ||
!isValidVec2(box[1])
)
return;
return box;
}
private drawBoxBounds(box: Vec2Box) {
for (const point of box) {
const [x, y] = point;
this.ctx.beginPath();
this.ctx.arc(x, y, 4, 0, 2 * Math.PI);
this.ctx.fill();
}
}
private keyDownEvent(e: KeyboardEvent) {
if (e.target !== document.body && e.target != null) return;
this.tools?.onKeyDown(e);
@ -386,7 +425,9 @@ export class HousePlannerCanvas {
onTouchStart(e: TouchEvent) {
e.preventDefault();
const touch = e.touches[0] || e.changedTouches[0];
this.dragging = true;
this.mouseClickPosition = [touch.clientX, touch.clientY];
this.realMousePos(touch.clientX, touch.clientY);
this.moved = false;
if (e.touches.length === 2) {
@ -410,6 +451,41 @@ export class HousePlannerCanvas {
this.dragging = false;
}
initViewport(boundingBox: Vec2Box) {
if (!isValidVec2(boundingBox[0]) || !isValidVec2(boundingBox[1])) return;
const [zoom, pos] = this.calculateViewport(boundingBox);
this.canvasZoom = zoom;
this.canvasPos = vec2MultiplyScalar(pos, -1);
this.canvas.dispatchEvent(
new CustomEvent<RepositionEvent>('hpc:position', {
detail: {
position: this.canvasPos,
zoom: this.canvasZoom,
},
})
);
}
private calculateViewport(box: Vec2Box): [number, Vec2] {
let [min, max] = box;
const gap = 80;
min = vec2Sub(min, [gap, gap]);
max = vec2Add(max, [gap, gap]);
const { width: windowWidth, height: windowHeight } =
this.canvas.parentElement!.getBoundingClientRect();
const diagonal = vec2Distance(min, max);
const target = vec2Sub(max, min);
const zoomScale = windowHeight / diagonal;
let scaledPos = vec2MultiplyScalar(min, zoomScale);
const scaled = vec2MultiplyScalar(target, zoomScale);
const overlap = vec2Sub([windowWidth, windowHeight], scaled);
scaledPos = vec2Sub(scaledPos, vec2DivideScalar(overlap, 2));
return [zoomScale, scaledPos];
}
private onMouseWheel(e: WheelEvent) {
e.preventDefault();
this.realMousePos(e.clientX, e.clientY);

View File

@ -3,8 +3,17 @@ import { LayerObject } from './interfaces';
export class HousePlannerCanvasClipboard {
public storedObjects: LayerObject[] = [];
public wasCutOperation = false;
storeToClipboard(items: LayerObject[], cut = false) {
this.wasCutOperation = cut;
// If we are cutting, we store the object by reference and return it all in its original state
if (cut) {
this.storedObjects = [...items];
return;
}
storeToClipboard(items: LayerObject[]) {
this.storedObjects = [
...items.map((item) => {
const itemCopy = {
@ -19,6 +28,12 @@ export class HousePlannerCanvasClipboard {
}
getFromClipboard(newId: number, selected = true): LayerObject[] {
if (this.wasCutOperation) {
const unalteredList = [...this.storedObjects];
this.storeToClipboard(this.storedObjects, false);
return unalteredList;
}
const newObjects = deepUnref(this.storedObjects);
return newObjects.map((item, index) => ({
...item,

View File

@ -1,6 +1,5 @@
import { Ref } from 'vue';
import { HousePlannerCanvas } from './canvas';
import { Layer, Vec2 } from './interfaces';
import { Layer, Vec2, Vec2Box } from './interfaces';
import { HousePlannerCanvasTools } from './tools';
export class HousePlanner {
@ -13,8 +12,9 @@ export class HousePlanner {
canvasDim: Vec2,
canvasPos: Vec2,
canvasZoom = 1,
editable = true
) {
editable = true,
boundingBox?: Vec2Box
): [number, Vec2] {
this.canvas = canvas;
this.manager = new HousePlannerCanvas(
canvas,
@ -35,6 +35,11 @@ export class HousePlanner {
}
this.manager.draw();
if (boundingBox) {
this.manager.initViewport(boundingBox);
}
return [this.manager.canvasZoom, this.manager.canvasPos];
}
cleanUp() {

View File

@ -2,6 +2,7 @@ import { HousePlannerCanvasHistory } from './history';
import { LayerObjectType } from './types';
export type Vec2 = [number, number];
export type Vec2Box = [Vec2, Vec2];
export interface LineSegment {
start?: Vec2;
end: Vec2;

View File

@ -297,8 +297,14 @@ export class HousePlannerCanvasTools implements ICanvasToolkit {
if (e.key === 'x' && e.ctrlKey) {
e.preventDefault();
if (this.selectedObjects.length && this.selectedLayer) {
this.clipboard.storeToClipboard(this.selectedObjects);
this.deleteSelection();
this.clipboard.storeToClipboard(this.selectedObjects, true);
this.deleteSelection([
{
object: this.clipboard,
property: 'wasCutOperation',
value: false,
},
]);
}
}
@ -361,7 +367,7 @@ export class HousePlannerCanvasTools implements ICanvasToolkit {
}
}
deleteSelection() {
deleteSelection(additionalHistory?: History<any>[]) {
if (!this.selectedObjects.length || !this.selectedLayer) return;
this.history.appendToHistory([
{
@ -369,6 +375,7 @@ export class HousePlannerCanvasTools implements ICanvasToolkit {
property: 'contents',
value: [...this.selectedLayer.contents],
} as History<typeof this.selectedLayer>,
...(additionalHistory || []),
]);
this.selectedLayer.contents = this.selectedLayer.contents.filter(

View File

@ -1,5 +1,5 @@
import { clamp } from '@vueuse/shared';
import { Vec2 } from './interfaces';
import { Vec2, Vec2Box } from './interfaces';
export const vec2Length = ([x, y]: Vec2) => Math.abs(Math.sqrt(x * x + y * y));
@ -69,3 +69,34 @@ export const rad2deg = (rad: number) => rad * (180 / Math.PI);
export const randomNumber = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1) + min);
export const boundingBox = (points: Vec2[], start: Vec2Box): Vec2Box => {
let minX = start[0][0];
let minY = start[0][1];
let maxX = start[1][0];
let maxY = start[1][1];
for (let i = 0; i < points.length; i++) {
if (points[i][0] > maxX) {
maxX = points[i][0];
}
if (points[i][0] < minX) {
minX = points[i][0];
}
if (points[i][1] > maxY) {
maxY = points[i][1];
}
if (points[i][1] < minY) {
minY = points[i][1];
}
}
return [
[minX, minY],
[maxX, maxY],
];
};
export const isValidVec2 = ([x, y]: Vec2) =>
x != null && y != null && !isNaN(x) && !isNaN(y);

View File

@ -20,9 +20,6 @@ const routes: RouteRecordRaw[] = [
name: 'planner',
path: '/planner',
component: HousePlanner,
meta: {
authenticated: false,
},
},
];

View File

@ -9,7 +9,7 @@ export const useUserStore = defineStore('user', {
state: () => {
return {
currentUser: null as User | null,
accessToken: useLocalStorage<string>('accessToken', null, {
accessToken: useLocalStorage<string | null>('accessToken', null, {
writeDefaults: false,
}),
};
@ -21,11 +21,17 @@ export const useUserStore = defineStore('user', {
actions: {
loginFromToken() {
const token = this.accessToken;
if (!token) return;
try {
const decoded = jwtDecode(token) as Record<string, unknown>;
if (!decoded.sub) {
throw new Error('Invalid token');
}
if ((decoded.exp as number) * 1000 < Date.now()) {
throw new Error('Expired token');
}
this.currentUser = {
sub: decoded.sub as string,
name: decoded.name as string,
@ -33,7 +39,8 @@ export const useUserStore = defineStore('user', {
picture: decoded.picture as string,
};
} catch {
// TODO
this.currentUser = null;
this.accessToken = null;
}
},
async login({ email, password }: { email: string; password: string }) {

View File

@ -1,47 +1,24 @@
<template>
<div class="relative h-full bg-gray-100">
<div
class="absolute top-0 left-0 z-10 rounded-br-md border-b-2 border-r-2 border-gray-200 bg-white px-2 py-2 shadow-lg"
>
<div class="flex flex-row items-center space-x-4 px-4">
<div class="flex flex-row items-center space-x-4">
<label for="building">Building:</label>
<select
id="building"
class="rounded-sm border-gray-300 py-1 focus:ring-2 focus:ring-blue-200"
v-model="selectedBuildingId"
@change="buildingSelected()"
>
<option v-for="building of buildings" :value="building.id">
{{ building.displayName }}
</option>
</select>
</div>
<div
class="flex flex-row items-center space-x-4"
v-if="selectedBuildingId"
>
<label for="floor">Floor:</label>
<select
id="floor"
class="rounded-sm border-gray-300 py-1 focus:ring-2 focus:ring-blue-200"
v-model="selectedFloorId"
>
<option v-for="floor of floors" :value="floor.id">
{{ floor.displayName }} ({{ floor.number }})
</option>
</select>
</div>
<div v-if="selectedFloorId">{{ status }}</div>
</div>
</div>
<PlannerBuildingSelect
:buildings="buildings"
:floors="floors"
:status="status"
:selected-building-id="selectedBuildingId"
:selected-floor-id="selectedFloorId"
@update:selected-building-id="(newValue) => buildingSelected(newValue)"
@update:selected-floor-id="(newValue) => (selectedFloorId = newValue)"
/>
<HousePlanner
v-if="selectedFloorId"
editable
ref="plannerRef"
:key="`planner-${selectedFloorId}`"
:floor-document="floorPlan"
@update="(layers, rooms) => updateDocument(layers, rooms)"
@update="
(layers, rooms, boundingBox) =>
updateDocument(layers, rooms, boundingBox)
"
@edited="status = 'Modified'"
/>
<div
@ -61,8 +38,9 @@ import { computed, onMounted, ref } from 'vue';
import { defaultRoomData } from '../components/house-planner/helpers/default-room';
import HousePlanner from '../components/house-planner/HousePlanner.vue';
import { FloorDocument } from '../components/house-planner/interfaces/floor-document.interface';
import PlannerBuildingSelect from '../components/house-planner/PlannerBuildingSelect.vue';
import { UpsertRoomItem } from '../interfaces/room.interfaces';
import { Line } from '../modules/house-planner/interfaces';
import { Line, Vec2Box } from '../modules/house-planner/interfaces';
import { useBuildingStore } from '../store/building.store';
import extractLinePoints from '../utils/extract-line-points';
const building = useBuildingStore();
@ -88,10 +66,11 @@ const floorPlan = computed(
})
);
const buildingSelected = async () => {
if (selectedBuildingId.value == null) return;
const buildingSelected = async (id: number) => {
if (id == null) return;
selectedFloorId.value = undefined;
await building.getFloors(selectedBuildingId.value);
selectedBuildingId.value = id;
await building.getFloors(id);
};
const updateRooms = async (data: FloorDocument, rooms: Line[]) => {
@ -136,10 +115,15 @@ const updateRooms = async (data: FloorDocument, rooms: Line[]) => {
return data;
};
const updateDocument = async (data: FloorDocument, rooms: Line[]) => {
const updateDocument = async (
data: FloorDocument,
rooms: Line[],
boundingBox?: Vec2Box
) => {
if (
!selectedBuildingId.value ||
!selectedFloorId.value ||
selectedFloorId.value !== data.id ||
!currentFloor.value ||
status.value === 'Saving...'
)
@ -150,7 +134,7 @@ const updateDocument = async (data: FloorDocument, rooms: Line[]) => {
data = await updateRooms(data, rooms);
// Prevent useless requests
const floorPlan = JSON.stringify(data);
const floorPlan = JSON.stringify({ ...data, boundingBox });
if (currentFloor.value.plan === floorPlan) {
status.value = 'Saved!';
return;