Skip to content

Commit

Permalink
feat: use rxjs to coordinate events and pipe state to back end using …
Browse files Browse the repository at this point in the history
…web socket
  • Loading branch information
rocwang committed Dec 5, 2020
1 parent 685fabf commit 3ef1166
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 107 deletions.
71 changes: 36 additions & 35 deletions src/deviceOrientation.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,56 @@
import { onMounted, onUnmounted, reactive, ref } from "vue";
import { onUnmounted, readonly, ref } from "vue";
import { BehaviorSubject, fromEvent } from "rxjs";
import { map, share, take } from "rxjs/operators";

const orientation = reactive<{ alpha: number | null; beta: number | null }>({
alpha: 0,
beta: 0,
});
export interface DeviceOrientation {
alpha: number | null;
beta: number | null;
}

export const isDeviceOrientationGranted = ref<boolean>(
const _isDeviceOrientationGranted = ref<boolean>(
typeof DeviceOrientationEvent.requestPermission !== "function"
);

export const isDeviceOrientationGranted = readonly(_isDeviceOrientationGranted);

export async function requestDeviceOrientation(): Promise<boolean> {
if (typeof DeviceOrientationEvent.requestPermission !== "function") {
return true;
}

const response = await DeviceOrientationEvent.requestPermission();
if (response === "granted") {
isDeviceOrientationGranted.value = true;
return true;
} else {
return false;
}
}
_isDeviceOrientationGranted.value = response === "granted";

function onDeviceOrientation(e: DeviceOrientationEvent) {
orientation.alpha = e.alpha;
orientation.beta = e.beta;
return _isDeviceOrientationGranted.value;
}

function checkDeviceOrientationGranted() {
isDeviceOrientationGranted.value = true;
}
export function getDeviceOrientationSubject(): BehaviorSubject<DeviceOrientation> {
const deviceOrientationSubject = new BehaviorSubject<DeviceOrientation>({
alpha: 0,
beta: 0,
});

export function useDeviceOrientation() {
onMounted(() => {
window.addEventListener("deviceorientation", onDeviceOrientation);
window.addEventListener(
"deviceorientation",
checkDeviceOrientationGranted,
{
once: true,
}
);
const deviceOrientation$ = fromEvent<DeviceOrientationEvent>(
window,
"deviceorientation"
).pipe(
map(({ alpha, beta }) => ({
alpha,
beta,
})),
share()
);

const sub1 = deviceOrientation$.pipe(take(1)).subscribe(() => {
_isDeviceOrientationGranted.value = true;
});

const sub2 = deviceOrientation$.subscribe(deviceOrientationSubject);

onUnmounted(() => {
window.removeEventListener("deviceorientation", onDeviceOrientation);
window.removeEventListener(
"deviceorientation",
checkDeviceOrientationGranted
);
sub1.unsubscribe();
sub2.unsubscribe();
});

return orientation;
return deviceOrientationSubject;
}
16 changes: 12 additions & 4 deletions src/pages/Stack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@
<script lang="ts">
import { computed, defineComponent, PropType, ref } from "vue";
import Compass from "@/components/Compass.vue";
import { useStack } from "@/stack.ts";
import { makeStack } from "@/stack.ts";
import {
useDeviceOrientation,
getDeviceOrientationSubject,
isDeviceOrientationGranted,
requestDeviceOrientation,
} from "@/deviceOrientation.ts";
import { type2PortraitImageUrl, ImageType } from "@/images.ts";
import { behaviorSubjectToRef } from "@/utilities";
import { pipeToPi } from "@/socket.ts";
export default defineComponent({
name: "Stack",
Expand All @@ -44,15 +46,21 @@ export default defineComponent({
setup(props) {
const src = computed(() => type2PortraitImageUrl(props.type));
const root = ref<HTMLDivElement | null>(null);
const velocity = useStack(src, root);
const orientation = useDeviceOrientation();
const velocitySubject = makeStack(src, root);
const velocity = behaviorSubjectToRef(velocitySubject);
const orientationSubject = getDeviceOrientationSubject();
const orientation = behaviorSubjectToRef(orientationSubject);
async function requestDeviceOrientationOrAlert() {
if (!(await requestDeviceOrientation())) {
alert("This app can't work without device orientation data.");
}
}
pipeToPi(velocitySubject, orientationSubject);
return {
src,
root,
Expand Down
37 changes: 37 additions & 0 deletions src/socket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { onUnmounted } from "vue";
import { BehaviorSubject, merge } from "rxjs";
import { DeviceOrientation } from "@/deviceOrientation";
import { webSocket } from "rxjs/webSocket";
import { map } from "rxjs/operators";

export function pipeToPi(
velocity: BehaviorSubject<number>,
orientation: BehaviorSubject<DeviceOrientation>
) {
const socketSubject = webSocket("wss:https://localhost:7777");

merge(
velocity.pipe(
map((velocity) => ({
velocity,
}))
),
orientation.pipe(
map((orientation) => ({
orientation,
}))
)
).subscribe(socketSubject);

const subscription = socketSubject.subscribe(
() => {},
(error) => {
console.log(error);
}
);

onUnmounted(() => {
socketSubject.error({ code: 1000, reason: "Stack.vue is unmounted" });
subscription.unsubscribe();
});
}
130 changes: 62 additions & 68 deletions src/stack.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import Konva from "konva";
import { onMounted, onUnmounted, ref, Ref, watchEffect } from "vue";
import { fromEventPattern, merge, Subscription } from "rxjs";
import { filter, scan, share, withLatestFrom } from "rxjs/operators";
import { onMounted, onUnmounted, Ref, watchEffect } from "vue";
import { BehaviorSubject, fromEventPattern, merge, Observable } from "rxjs";
import { filter, map, scan, share, tap, withLatestFrom } from "rxjs/operators";

declare interface PaperState {
interface PaperState {
t: number;
y: number;
v: number;
}

export function useStack(
export function makeStack(
src: Ref,
containerRef: Ref<HTMLDivElement | null>
): Ref<number> {
const velocityRef = ref<number>(0);
let stage: null | Konva.Stage = null;
let subscription: null | Subscription = null;
): BehaviorSubject<number> {
const velocitySubject = new BehaviorSubject<number>(0);

onMounted(async () => {
const container = containerRef.value as HTMLDivElement;
stage = new Konva.Stage({
const stage = new Konva.Stage({
container,
width: container.scrollWidth,
height: container.scrollHeight,
});

const layer = new Konva.Layer();
subscription = bindEvents(layer, stage, velocityRef);

const velocity$ = getVelocity$FromDrag(layer, stage);
const subscription = velocity$.subscribe(velocitySubject);

stage.add(layer);

Expand All @@ -39,21 +39,20 @@ export function useStack(
layer.add(...imageStack);
layer.draw();
});
});

onUnmounted(() => {
subscription && subscription.unsubscribe();
stage && stage.destroy();
onUnmounted(() => {
subscription.unsubscribe();
stage.destroy();
});
});

return velocityRef;
return velocitySubject;
}

function bindEvents(
function getVelocity$FromDrag(
layer: Konva.Layer,
stage: Konva.Stage,
velocityRef: Ref<number>
): Subscription {
stage: Konva.Stage
): Observable<number> {
const state$ = merge(
fromEventPattern(
(handler) => layer.on("dragstart", handler),
Expand All @@ -63,61 +62,56 @@ function bindEvents(
(handler) => layer.on("dragmove", handler),
(handler) => layer.off("dragmove", handler)
)
)
.pipe(
scan(
(last: PaperState, e: unknown) => {
const t = performance.now();
const y = (e as Konva.KonvaEventObject<DragEvent>).target.y();
).pipe(
scan(
(last: PaperState, e: unknown) => {
const t = performance.now();
const y = (e as Konva.KonvaEventObject<DragEvent>).target.y();

const v = t === 0 ? 0 : (y - last.y) / (t - last.t);
const v = t === 0 ? 0 : (y - last.y) / (t - last.t);

return { t, y, v };
},
{ t: 0, y: 0, v: 0 }
)
return { t, y, v };
},
{ t: 0, y: 0, v: 0 }
)
.pipe(share());
);

const velocity$ = fromEventPattern(
return fromEventPattern(
(handler) => layer.on("dragend", handler),
(handler) => layer.off("dragend", handler)
)
.pipe(
withLatestFrom(state$, (e: unknown, state) => ({
e: e as Konva.KonvaEventObject<DragEvent>,
state,
}))
)
).pipe(
withLatestFrom(state$, (e: unknown, state) => ({
e: e as Konva.KonvaEventObject<DragEvent>,
state,
})),
// The image must have a large enough upward speed to fly out
.pipe(filter(({ state }) => state.v < -1));

return velocity$.subscribe(({ state, e }) => {
// Update velocity ref for Vue
velocityRef.value = state.v;

const target = e.target as Konva.Image;

target.draggable(false);
target.listening(false);

const targetY = -stage.height() * 1.2;
const distance = targetY - target.y();
const duration = distance / state.v / 1000;

target.to({
x: target.x(),
y: targetY,
duration,
easing: Konva.Easings.Linear,
onFinish: () => {
target.setAttrs({
...createInitialImageConfig(stage),
zIndex: 0,
});
},
});
});
filter(({ state }) => state.v < -1),
tap(({ state, e }) => {
const target = e.target as Konva.Image;

target.draggable(false);
target.listening(false);

const targetY = -stage.height() * 1.2;
const distance = targetY - target.y();
const duration = distance / state.v / 1000;

target.to({
x: target.x(),
y: targetY,
duration,
easing: Konva.Easings.Linear,
onFinish: () => {
target.setAttrs({
...createInitialImageConfig(stage),
zIndex: 0,
});
},
});
}),
map(({ state }) => state.v),
share()
);
}

function getImage(src: string): Promise<HTMLImageElement> {
Expand Down
16 changes: 16 additions & 0 deletions src/utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { BehaviorSubject } from "rxjs";
import { onUnmounted, ref, Ref, readonly } from "vue";
import { DeepReadonly, UnwrapRef } from "@vue/reactivity";

export function behaviorSubjectToRef<T>(
s: BehaviorSubject<T>
): DeepReadonly<Ref<UnwrapRef<T>>> {
const reference = ref<T>(s.value);

const subscription = s.subscribe((v: T) => {
reference.value = v as UnwrapRef<T>;
});
onUnmounted(() => subscription.unsubscribe());

return readonly(reference);
}

0 comments on commit 3ef1166

Please sign in to comment.