valcore commited on
Commit
575e6bd
·
1 Parent(s): 96dc04e

feat: add FloorPlan Svelte component with SVG rendering and drag interaction

Browse files
floorplan/frontend/FloorPlan.svelte ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { createEventDispatcher } from "svelte";
3
+
4
+ export let corners: [number, number][] = [];
5
+ export let furnitures: Array<{
6
+ object: string;
7
+ localisation: [number, number, number, number]; // [ymin, xmin, ymax, xmax]
8
+ description: string;
9
+ }> = [];
10
+ export let interactive = true;
11
+
12
+ const SVG_WIDTH = 800;
13
+ const SVG_HEIGHT = 600;
14
+
15
+ const dispatch = createEventDispatcher<{
16
+ change: { corners: [number, number][]; furnitures: typeof furnitures };
17
+ }>();
18
+
19
+ // Bound SVG element — used for pointer capture and coordinate scaling
20
+ let svgEl: SVGSVGElement;
21
+
22
+ // Room axis-aligned bounding box for clamping
23
+ $: roomMinX = corners.length ? Math.min(...corners.map((c) => c[0])) : 0;
24
+ $: roomMaxX = corners.length ? Math.max(...corners.map((c) => c[0])) : SVG_WIDTH;
25
+ $: roomMinY = corners.length ? Math.min(...corners.map((c) => c[1])) : 0;
26
+ $: roomMaxY = corners.length ? Math.max(...corners.map((c) => c[1])) : SVG_HEIGHT;
27
+
28
+ $: polygonPoints = corners.map((c) => c.join(",")).join(" ");
29
+
30
+ // Per-furniture drag offsets [dx, dy] in SVG user-space pixels
31
+ let offsets: [number, number][] = [];
32
+ $: {
33
+ // Only reset offsets when no drag is active to avoid corrupting mid-drag state
34
+ if (activeIdx === null) {
35
+ offsets = furnitures.map(() => [0, 0]);
36
+ }
37
+ }
38
+
39
+ // Drag state
40
+ let activeIdx: number | null = null;
41
+ let pointerStartX = 0; // in SVG user-space pixels
42
+ let pointerStartY = 0;
43
+ let startDx = 0;
44
+ let startDy = 0;
45
+ let savedOffsets: [number, number][] = [];
46
+
47
+ function clampOffset(
48
+ dx: number,
49
+ dy: number,
50
+ xmin: number,
51
+ ymin: number,
52
+ xmax: number,
53
+ ymax: number
54
+ ): [number, number] {
55
+ const clampedDx = Math.max(roomMinX - xmin, Math.min(roomMaxX - xmax, dx));
56
+ const clampedDy = Math.max(roomMinY - ymin, Math.min(roomMaxY - ymax, dy));
57
+ return [clampedDx, clampedDy];
58
+ }
59
+
60
+ function clientToSvg(clientX: number, clientY: number): [number, number] {
61
+ const rect = svgEl.getBoundingClientRect();
62
+ const scaleX = SVG_WIDTH / rect.width;
63
+ const scaleY = SVG_HEIGHT / rect.height;
64
+ return [
65
+ (clientX - rect.left) * scaleX,
66
+ (clientY - rect.top) * scaleY,
67
+ ];
68
+ }
69
+
70
+ function onPointerDown(e: PointerEvent, i: number) {
71
+ if (!interactive) return;
72
+ e.preventDefault();
73
+ activeIdx = i;
74
+ const [sx, sy] = clientToSvg(e.clientX, e.clientY);
75
+ pointerStartX = sx;
76
+ pointerStartY = sy;
77
+ startDx = offsets[i][0];
78
+ startDy = offsets[i][1];
79
+ savedOffsets = offsets.map((o) => [o[0], o[1]] as [number, number]);
80
+ // Capture on the SVG so pointermove/pointerup fire on the SVG during drag
81
+ svgEl.setPointerCapture(e.pointerId);
82
+ }
83
+
84
+ function onPointerMove(e: PointerEvent) {
85
+ if (activeIdx === null) return;
86
+ const i = activeIdx;
87
+ const [ymin, xmin, ymax, xmax] = furnitures[i].localisation;
88
+ const [sx, sy] = clientToSvg(e.clientX, e.clientY);
89
+ const rawDx = startDx + (sx - pointerStartX);
90
+ const rawDy = startDy + (sy - pointerStartY);
91
+ const [dx, dy] = clampOffset(rawDx, rawDy, xmin, ymin, xmax, ymax);
92
+ offsets = offsets.map((o, idx) => (idx === i ? [dx, dy] : o)) as [number, number][];
93
+ }
94
+
95
+ function onPointerUp(_e: PointerEvent) {
96
+ svgEl.releasePointerCapture(_e.pointerId);
97
+ if (activeIdx === null) return;
98
+ const i = activeIdx;
99
+ const [ymin, xmin, ymax, xmax] = furnitures[i].localisation;
100
+ const [dx, dy] = offsets[i];
101
+
102
+ const updatedFurnitures = furnitures.map((f, idx) => {
103
+ if (idx !== i) return f;
104
+ return {
105
+ ...f,
106
+ localisation: [ymin + dy, xmin + dx, ymax + dy, xmax + dx] as [number, number, number, number],
107
+ };
108
+ });
109
+
110
+ // Absorb offset into localisation, reset offset
111
+ offsets = offsets.map((o, idx) => (idx === i ? [0, 0] : o)) as [number, number][];
112
+ activeIdx = null;
113
+
114
+ dispatch("change", { corners, furnitures: updatedFurnitures });
115
+ }
116
+
117
+ function onPointerCancel(_e: PointerEvent) {
118
+ svgEl.releasePointerCapture(_e.pointerId);
119
+ if (activeIdx === null) return;
120
+ offsets = savedOffsets;
121
+ activeIdx = null;
122
+ }
123
+ </script>
124
+
125
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
126
+ <svg
127
+ bind:this={svgEl}
128
+ width={SVG_WIDTH}
129
+ height={SVG_HEIGHT}
130
+ style="border: 1px solid #ccc; background: #fafafa; display: block;"
131
+ on:pointermove={onPointerMove}
132
+ on:pointerup={onPointerUp}
133
+ on:pointercancel={onPointerCancel}
134
+ >
135
+ <!-- Room outline -->
136
+ <polygon points={polygonPoints} fill="none" stroke="#333" stroke-width="2" />
137
+
138
+ <!-- Furniture bounding boxes -->
139
+ {#each furnitures as f, i}
140
+ {@const [ymin, xmin, ymax, xmax] = f.localisation}
141
+ {@const [dx, dy] = offsets[i] ?? [0, 0]}
142
+ {@const isActive = activeIdx === i}
143
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
144
+ <g
145
+ transform="translate({dx}, {dy})"
146
+ style="cursor: {interactive ? (isActive ? 'grabbing' : 'grab') : 'default'};"
147
+ on:pointerdown={(e) => onPointerDown(e, i)}
148
+ >
149
+ <rect
150
+ x={xmin}
151
+ y={ymin}
152
+ width={xmax - xmin}
153
+ height={ymax - ymin}
154
+ fill={isActive ? "rgba(249, 115, 22, 0.3)" : "rgba(100, 149, 237, 0.3)"}
155
+ stroke={isActive ? "#f97316" : "#6495ed"}
156
+ stroke-width={isActive ? 2.5 : 1.5}
157
+ />
158
+ <text
159
+ x={xmin + (xmax - xmin) / 2}
160
+ y={ymin + (ymax - ymin) / 2}
161
+ text-anchor="middle"
162
+ dominant-baseline="middle"
163
+ font-size="12"
164
+ fill="#222"
165
+ pointer-events="none"
166
+ >{i + 1} · {f.object}</text>
167
+ </g>
168
+ {/each}
169
+ </svg>
floorplan/frontend/Index.svelte CHANGED
@@ -1,112 +1,71 @@
1
  <svelte:options accessors={true} />
2
 
3
  <script lang="ts">
4
- import type { SimpleTextboxProps, SimpleTextboxEvents } from "./types";
5
- import { Gradio } from "@gradio/utils";
6
- import { BlockTitle } from "@gradio/atoms";
7
- import { Block } from "@gradio/atoms";
8
- import { StatusTracker } from "@gradio/statustracker";
9
- import { tick } from "svelte";
10
 
11
- const props = $props();
12
- const gradio = new Gradio<SimpleTextboxEvents, SimpleTextboxProps>(props);
 
 
 
13
 
14
- let el: HTMLTextAreaElement | HTMLInputElement;
15
- const container = true;
16
- let old_value = $state(gradio.props.value);
 
17
 
18
- async function handle_keypress(e: KeyboardEvent): Promise<void> {
19
- await tick();
20
- if (e.key === "Enter") {
21
- e.preventDefault();
22
- gradio.dispatch("submit");
23
- }
24
- }
25
 
26
- $effect(() => {
27
- if (old_value != gradio.props.value) {
28
- old_value = gradio.props.value;
29
- gradio.dispatch("change");
30
- }
31
- });
 
 
 
 
 
 
 
 
 
32
  </script>
33
 
34
  <Block
35
- visible={gradio.shared.visible}
36
- elem_id={gradio.shared.elem_id}
37
- elem_classes={gradio.shared.elem_classes}
38
- scale={gradio.shared.scale}
39
- min_width={gradio.shared.min_width}
40
- allow_overflow={false}
41
- padding={true}
42
- rtl={gradio.props.rtl}
43
  >
44
- {#if gradio.shared.loading_status}
45
- <StatusTracker
46
- autoscroll={gradio.shared.autoscroll}
47
- i18n={gradio.i18n}
48
- {...gradio.shared.loading_status}
49
- on_clear_status={() =>
50
- gradio.dispatch("clear_status", gradio.shared.loading_status)}
51
- />
52
- {/if}
53
 
54
- <label class:container>
55
- <BlockTitle show_label={gradio.shared.show_label} info={undefined}
56
- >{gradio.shared.label}</BlockTitle
57
- >
58
-
59
- <input
60
- data-testid="textbox"
61
- type="text"
62
- class="scroll-hide"
63
- bind:value={gradio.props.value}
64
- bind:this={el}
65
- placeholder={gradio.props.placeholder}
66
- disabled={!gradio.shared.interactive}
67
- dir={gradio.props.rtl ? "rtl" : "ltr"}
68
- on:input={() => gradio.dispatch("input")}
69
- on:keypress={handle_keypress}
70
- />
71
- </label>
72
  </Block>
73
-
74
- <style>
75
- label {
76
- display: block;
77
- width: 100%;
78
- }
79
-
80
- input {
81
- display: block;
82
- position: relative;
83
- outline: none !important;
84
- box-shadow: var(--input-shadow);
85
- background: var(--input-background-fill);
86
- padding: var(--input-padding);
87
- width: 100%;
88
- color: var(--body-text-color);
89
- font-weight: var(--input-text-weight);
90
- font-size: var(--input-text-size);
91
- line-height: var(--line-sm);
92
- border: none;
93
- }
94
- .container > input {
95
- border: var(--input-border-width) solid var(--input-border-color);
96
- border-radius: var(--input-radius);
97
- }
98
- input:disabled {
99
- -webkit-text-fill-color: var(--body-text-color);
100
- -webkit-opacity: 1;
101
- opacity: 1;
102
- }
103
-
104
- input:focus {
105
- box-shadow: var(--input-shadow-focus);
106
- border-color: var(--input-border-color-focus);
107
- }
108
-
109
- input::placeholder {
110
- color: var(--input-placeholder-color);
111
- }
112
- </style>
 
1
  <svelte:options accessors={true} />
2
 
3
  <script lang="ts">
4
+ import { tick } from "svelte";
5
+ import { Gradio } from "@gradio/utils";
6
+ import { Block } from "@gradio/atoms";
7
+ import { StatusTracker } from "@gradio/statustracker";
8
+ import FloorPlan from "./FloorPlan.svelte";
 
9
 
10
+ type FurnitureItem = {
11
+ object: string;
12
+ localisation: [number, number, number, number]; // [ymin, xmin, ymax, xmax]
13
+ description: string;
14
+ };
15
 
16
+ type FloorPlanValue = {
17
+ corners: [number, number][];
18
+ furnitures: FurnitureItem[];
19
+ };
20
 
21
+ interface FloorPlanProps {
22
+ value: FloorPlanValue | null;
23
+ }
 
 
 
 
24
 
25
+ interface FloorPlanEvents {
26
+ change: never;
27
+ clear_status: any;
28
+ }
29
+
30
+ const props = $props();
31
+ const gradio = new Gradio<FloorPlanEvents, FloorPlanProps>(props, {
32
+ value: null,
33
+ });
34
+
35
+ async function handleChange(event: CustomEvent<FloorPlanValue>) {
36
+ gradio.props.value = event.detail;
37
+ await tick();
38
+ gradio.dispatch("change");
39
+ }
40
  </script>
41
 
42
  <Block
43
+ visible={gradio.shared.visible}
44
+ elem_id={gradio.shared.elem_id}
45
+ elem_classes={gradio.shared.elem_classes}
46
+ scale={gradio.shared.scale}
47
+ min_width={gradio.shared.min_width}
48
+ allow_overflow={false}
49
+ padding={true}
 
50
  >
51
+ {#if gradio.shared.loading_status}
52
+ <StatusTracker
53
+ autoscroll={gradio.shared.autoscroll}
54
+ i18n={gradio.i18n}
55
+ {...gradio.shared.loading_status}
56
+ on_clear_status={() =>
57
+ gradio.dispatch("clear_status", gradio.shared.loading_status)}
58
+ />
59
+ {/if}
60
 
61
+ {#if gradio.props.value}
62
+ <FloorPlan
63
+ corners={gradio.props.value.corners}
64
+ furnitures={gradio.props.value.furnitures}
65
+ interactive={gradio.shared.interactive}
66
+ on:change={handleChange}
67
+ />
68
+ {:else}
69
+ <p style="color: #999; padding: 1rem;">No floor plan data provided.</p>
70
+ {/if}
 
 
 
 
 
 
 
 
71
  </Block>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
floorplan/frontend/floorplan.test.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ // Logic mirror test for clampOffset — tests the algorithm, not the component import
4
+ function clampOffset(
5
+ dx: number,
6
+ dy: number,
7
+ xmin: number,
8
+ ymin: number,
9
+ xmax: number,
10
+ ymax: number,
11
+ roomMinX: number,
12
+ roomMinY: number,
13
+ roomMaxX: number,
14
+ roomMaxY: number
15
+ ): [number, number] {
16
+ const clampedDx = Math.max(roomMinX - xmin, Math.min(roomMaxX - xmax, dx));
17
+ const clampedDy = Math.max(roomMinY - ymin, Math.min(roomMaxY - ymax, dy));
18
+ return [clampedDx, clampedDy];
19
+ }
20
+
21
+ describe("clampOffset", () => {
22
+ const room = { minX: 50, minY: 50, maxX: 550, maxY: 450 };
23
+
24
+ it("allows movement within room", () => {
25
+ const [dx, dy] = clampOffset(50, 0, 100, 100, 300, 200, room.minX, room.minY, room.maxX, room.maxY);
26
+ expect(dx).toBe(50);
27
+ expect(dy).toBe(0);
28
+ });
29
+
30
+ it("clamps at right wall", () => {
31
+ const [dx] = clampOffset(300, 0, 100, 100, 300, 200, room.minX, room.minY, room.maxX, room.maxY);
32
+ expect(dx).toBe(250);
33
+ });
34
+
35
+ it("clamps at left wall", () => {
36
+ const [dx] = clampOffset(-200, 0, 100, 100, 300, 200, room.minX, room.minY, room.maxX, room.maxY);
37
+ expect(dx).toBe(-50);
38
+ });
39
+
40
+ it("clamps at bottom wall", () => {
41
+ const [, dy] = clampOffset(0, 400, 100, 100, 300, 200, room.minX, room.minY, room.maxX, room.maxY);
42
+ expect(dy).toBe(250);
43
+ });
44
+
45
+ it("clamps at top wall", () => {
46
+ const [, dy] = clampOffset(0, -200, 100, 100, 300, 200, room.minX, room.minY, room.maxX, room.maxY);
47
+ expect(dy).toBe(-50);
48
+ });
49
+ });
floorplan/frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
floorplan/frontend/package.json CHANGED
@@ -28,7 +28,8 @@
28
  "svelte": "^5.48.0"
29
  },
30
  "devDependencies": {
31
- "@gradio/preview": "0.16.0"
 
32
  },
33
  "peerDependencies": {
34
  "svelte": "^5.48.0"
@@ -38,4 +39,4 @@
38
  "url": "git+https://github.com/gradio-app/gradio.git",
39
  "directory": "js/simpletextbox"
40
  }
41
- }
 
28
  "svelte": "^5.48.0"
29
  },
30
  "devDependencies": {
31
+ "@gradio/preview": "0.16.0",
32
+ "vitest": "^4.1.0"
33
  },
34
  "peerDependencies": {
35
  "svelte": "^5.48.0"
 
39
  "url": "git+https://github.com/gradio-app/gradio.git",
40
  "directory": "js/simpletextbox"
41
  }
42
+ }