diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 826fd97..057e5ed 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,7 +14,7 @@ export { useInteractionsAPI, // useMeasurementsWebsocket, // useMetricAPI, -// useMineAPI, + useMineAPI, usePermissionAPI, // usePreferenceAPI, // useSecurity, diff --git a/src/interactions/InteractionSettings.tsx b/src/interactions/InteractionSettings.tsx index d46c8b3..f743976 100644 --- a/src/interactions/InteractionSettings.tsx +++ b/src/interactions/InteractionSettings.tsx @@ -63,8 +63,9 @@ import { makeStyles } from "@mui/styles"; import { green, red } from "@mui/material/colors"; import { LocalizationProvider, TimePicker } from "@mui/x-date-pickers"; import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; -import dayjs from "dayjs"; +// import dayjs from "dayjs"; import CancelSubmit from "common/CancelSubmit"; +import { Sensor } from "ventilation/drawable/Sensor"; // import { Sensor } from "ventilation/drawable/Sensor"; @@ -112,7 +113,7 @@ interface Props { closeDialogCallback: Function; refreshCallback: Function; canEdit?: boolean; - // sensor?: Sensor; + sensor?: Sensor; } export default function InteractionSettings(props: Props) { @@ -126,7 +127,7 @@ export default function InteractionSettings(props: Props) { closeDialogCallback, refreshCallback, canEdit, - // sensor + sensor } = props; const theme = useTheme(); const { success, error } = useSnackbar(); @@ -206,11 +207,11 @@ export default function InteractionSettings(props: Props) { or(interaction.settings.result, undefined) ); - // if (sensor) { - // interaction.settings.subtype = 2; - // interaction.settings.nodeOne = sensor.index; - // interaction.settings.nodeTwo = sensor.index; - // } + if (sensor) { + interaction.settings.subtype = 2; + interaction.settings.nodeOne = sensor.index; + interaction.settings.nodeTwo = sensor.index; + } setIsRemoveInteractionOpen(false); setNumConditions(numConditions); @@ -982,15 +983,15 @@ export default function InteractionSettings(props: Props) { )} - {/* {multiNodeSource() && !sensor && ( + {multiNodeSource() && !sensor && ( - + - + {(subtypeDropdown === 2 || subtypeDropdown === 3 || subtypeDropdown === 4) && ( )} - + {subtypeDropdown === 3 && ( - )} */} + )} ); }; @@ -1564,7 +1565,7 @@ export default function InteractionSettings(props: Props) { - {/* {canEdit && ( @@ -1574,7 +1575,7 @@ export default function InteractionSettings(props: Props) { disabled={!isInteractionValid(interaction) || invalidConditionValues()}> Submit - )} */} + )} ); diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx index a339b70..7a71529 100644 --- a/src/navigation/Router.tsx +++ b/src/navigation/Router.tsx @@ -2,29 +2,23 @@ import { lazy, Suspense } from "react"; import LoadingScreen from "../app/LoadingScreen"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { useAuth0 } from "@auth0/auth0-react"; -// import Teams from "pages/Teams"; -// import Users from "pages/Users"; -// import TeamPage from "pages/Team"; import Header from "app/Header"; import Logout from "pages/Logout"; -import Devices from "pages/Devices"; -// import DevicePage from "pages/Device"; -// import GroupsPage from "pages/Groups"; -// import GroupPage from "pages/Group"; import { ErrorBoundary } from "react-error-boundary"; -import DeviceComponent from "pages/DeviceComponent"; -import Bins from "pages/Bins"; import { getWhitelabel } from "services/whiteLabel"; -import Bin from "pages/Bin"; const DeviceHistory = lazy(() => import("pages/DeviceHistory")); const DevicePage = lazy(() => import("pages/Device")); +const Devices = lazy(() => import("pages/Devices")); const GroupsPage = lazy(() => import("pages/Groups")); const GroupPage = lazy(() => import("pages/Group")); const Teams = lazy(() => import("pages/Teams")); const Users = lazy(() => import("pages/Users")); const TeamPage = lazy(() => import("pages/Team")); const BinPage = lazy(() => import("pages/Bin")); +const Bins = lazy(() => import("pages/Bins")); +const Mines = lazy(() => import("pages/Mines")); +const DeviceComponent = lazy(() => import("pages/DeviceComponent")); interface Props { toggleTheme: () => void; @@ -48,6 +42,7 @@ export default function Router(props: Props) { } /> } /> } /> + } /> ) } @@ -112,7 +107,7 @@ export default function Router(props: Props) { /> } + element={} /> {/* { + return ( +
+ + } + /> + {/* } + /> */} + {/* } + /> + } + /> */} + } + /> + +
+ ); + }; + const GroupsRoute = () => { return ( diff --git a/src/navigation/SideNavigator.tsx b/src/navigation/SideNavigator.tsx index 553543d..7f18ac9 100644 --- a/src/navigation/SideNavigator.tsx +++ b/src/navigation/SideNavigator.tsx @@ -1,12 +1,37 @@ import { ChevronRight, People, Person } from "@mui/icons-material"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; -import { darken, Divider, Grid2 as Grid, IconButton, lighten, List, ListItemButton, ListItemIcon, ListItemText, SwipeableDrawer, Theme, Toolbar, Tooltip, useTheme } from "@mui/material"; +import { + darken, + Divider, + Grid2 as Grid, + IconButton, + lighten, + List, + ListItemButton, + ListItemIcon, + ListItemText, + SwipeableDrawer, + Theme, + Toolbar, + Tooltip, + useTheme +} from "@mui/material"; import classNames from "classnames"; import { useWidth } from "../hooks/useWidth"; import { makeStyles } from "@mui/styles"; import BindaptIcon from "../products/Bindapt/BindaptIcon"; import { Link, useLocation } from "react-router-dom"; import BinsIcon from "products/Bindapt/BinsIcon"; +import { useGlobalState } from "providers"; +import { + hasTutorialPlaylist, + IsAdaptiveAgriculture, + IsAdCon, + isBXT, + IsMiVent, + IsOmniAir +} from "services/whiteLabel"; +import MiningIcon from "products/ventilation/MiningIcon"; const drawerWidth = 230; @@ -80,6 +105,7 @@ export default function SideNavigator(props: Props) { const width = useWidth(); const classes = useStyles(); const location = useLocation(); + const [{ user }] = useGlobalState() const getClasses = (page: string) => { if (page === "/device") { @@ -101,7 +127,7 @@ export default function SideNavigator(props: Props) { } const authenticatedSideMenu = () => { - + const isMiVent = IsMiVent(); return ( @@ -132,6 +158,22 @@ export default function SideNavigator(props: Props) { {open && } + {(isMiVent || user.hasFeature("admin")) && ( + + + + + + {open && } + + + )} { + return ({ + gutter: { + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + [theme.breakpoints.up("sm")]: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2) + }, + [theme.breakpoints.up("md")]: { + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3) + } + }, + actionToolbar: { + paddingLeft: 0, + paddingRight: theme.spacing(1), + [theme.breakpoints.up("sm")]: { + paddingRight: theme.spacing(2) + } + }, + densePaddingY: { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1) + }, + mobileContent: { + maxWidth: "100%", + margin: 0, + padding: `${theme.spacing(1)}px ${theme.spacing(1)}px` + }, + tableContainer: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + [theme.breakpoints.up("sm")]: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2) + }, + [theme.breakpoints.up("md")]: { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(3) + } + } + }) +}); + +interface MineRow { + mine: pond.Mine; +} + +const mineColumns: Column[] = [ + { + title: "Name", + cellStyle: { margin: 1 }, + render: row => { + let key = row.mine.settings?.key ? row.mine.settings?.key : "No Key found"; + let name = row.mine.settings?.name ? row.mine.settings?.name : "Mine"; + return ( + + + + ); + } + } +]; + +export default function Mines() { + const classes = useStyles(); + const mineAPI = useMineAPI(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [mineTableData, setMineTableData] = useState([]); + const [minesTotal, setMinesTotal] = useState(0); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [mineDialog, setMineDialog] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const ColorAdd = withStyles((theme: Theme) => ({ + root: { + color: theme.palette.getContrastText(theme.palette.primary.main), + backgroundColor: theme.palette.primary.main, + marginTop: "auto", + marginBottom: "auto", + borderRadius: "4px", + "&:hover": { + backgroundColor: theme.palette.primary.light, + cursor: "pointer" + }, + "&:disabled": { + backgroundColor: "rgba(0,0,0,0)", + color: theme.palette.getContrastText(theme.palette.primary.main) + } + } + }))(Add); + + const load = useCallback(() => { + setIsLoading(true); + mineAPI + .listMines(pageSize, pageSize * page, "desc", undefined, searchValue, false) + .then(resp => { + let mineData: MineRow[] = []; + resp.data.mines.forEach(mine => { + mineData.push({ + mine: mine + }); + }); + setMineTableData(mineData); + setMinesTotal(resp.data.total); + }) + .finally(() => { + setIsLoading(false); + }); + }, [pageSize, page, mineAPI, setIsLoading, searchValue]); + + useEffect(() => { + load(); + }, [load]); + + const handleRowsPerPageChange = (event: any) => { + const newRowsPerPage = parseInt(event.target.value, 10); // Get selected value + setPageSize(newRowsPerPage); + setPage(0) + } + + const renderTitle = () => { + return ( + + {/* */} + + Mines + + setMineDialog(true)} /> + + ) + } + + const onClick = (data: MineRow) => { + const url = or(data.mine.settings?.key, "") + navigate(url) + } + + const minesTable = () => { + return ( + + title={renderTitle} + rows={mineTableData} + columns={mineColumns} + total={minesTotal} + page={page} + pageSize={pageSize} + setSearchText={setSearchValue} + setPage={setPage} + handleRowsPerPageChange={handleRowsPerPageChange} + hideKeys + isLoading={isLoading} + onRowClick={onClick} + /> + ) + } + + const tableContent = () => { + return
{minesTable()}
; + }; + + const closeCallback = () => { + setMineDialog(false); + }; + + return ( + + {tableContent()} + + + ); +} diff --git a/src/pages/VentEditor.tsx b/src/pages/VentEditor.tsx new file mode 100644 index 0000000..bcb8622 --- /dev/null +++ b/src/pages/VentEditor.tsx @@ -0,0 +1,202 @@ +import { Theme, useTheme } from "@mui/material"; +import React, { useCallback, useEffect, useState } from "react"; +import Editor from "ventilation/Editor"; +import PageContainer from "./PageContainer"; +import EditorSidebar from "ventilation/EditorSidebar"; +import EditorHeader from "ventilation/EditorHeader"; +import { pond } from "protobuf-ts/pond"; +import { loadPlaceable, Placeable } from "ventilation/drawable/Placeable"; +import { Component } from "models"; +// import { useRouteMatch } from "react-router"; +// import { MatchParams } from "navigation/Routes"; +import { useMineAPI } from "hooks"; +import { Sensor } from "ventilation/drawable/Sensor"; +import { useParams } from "react-router-dom"; +import { makeStyles } from "@mui/styles"; + +const useStyles = makeStyles((theme: Theme) => { + return ({ + header: { + width: "100%", + height: theme.spacing(7), + background: "rgba(25, 25, 25, 1)", + margin: 0, + padding: 0, + display: "flex", + alignItems: "center", + borderBottom: "1px solid rgb(75, 75, 75)" + }, + middleButtons: { + display: "flex", + alignItems: "center", + margin: "auto" + }, + divider: { + height: "42px", + width: "2px", + margin: theme.spacing(1) + }, + editor: { + // border: "1px solid red", + width: "100%", + height: "auto", + margin: 0, + padding: 0 + } + }); +}); + +enum Mode { + NORMAL, + MOVE, + ADD, + DELETE +} + +export default function Ventilation() { + const classes = useStyles(); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [mode, setMode] = useState(Mode.NORMAL); + // const match = useRouteMatch(); + const mineKey = useParams<{ mineKey: string }>()?.mineKey ?? ""; + // const mineKey = match.params.mineKey; + const undoRef = React.useRef(null); + const redoRef = React.useRef(null); + const saveRef = React.useRef(null); + const addBlockRef = React.useRef(null); + const theme = useTheme(); + const [devices, setDevices] = useState([]); + const [components, setComponents] = useState([]); + const [blocks, setBlocks] = useState([]); + const [mine, setMine] = useState(pond.MineSettings.create()); + const mineAPI = useMineAPI(); + const [loading, setLoading] = useState(false); + const [permissions, setPermissions] = useState([]); + const [componentPreferences, setComponentPreferences] = useState< + Map + >(new Map()); + const [sensors, setSensors] = useState([]); + const [componentDevices, setComponentDevices] = useState>(new Map()); + + const handleResize = (node: any) => { + setHeight(node.getBoundingClientRect().height); + setWidth(node.getBoundingClientRect().width); + }; + + const pageContainer = useCallback((node: any) => { + if (node) { + handleResize(node); + window.addEventListener("resize", () => handleResize(node), false); + } + }, []); + + useEffect(() => { + if (loading) return; + if (!mineKey) return; + setLoading(true); + mineAPI + .getMine(mineKey) + .then((resp: { data: pond.IGetMineResponse | undefined; }) => { + if (resp.data && resp.data.mine && resp.data.mine.settings) { + let data = pond.GetMineResponse.create(resp.data); + let perms: pond.Permission[] = []; + data.permissions.forEach(p => { + if (typeof p === "string" && p === "PERMISSION_READ") { + perms.push(pond.Permission.PERMISSION_READ); + } else if (typeof p === "string" && p === "PERMISSION_WRITE") { + perms.push(pond.Permission.PERMISSION_WRITE); + } else if (typeof p === "string" && p === "PERMISSION_SHARE") { + perms.push(pond.Permission.PERMISSION_SHARE); + } else if (typeof p === "string" && p === "PERMISSION_USERS") { + perms.push(pond.Permission.PERMISSION_USERS); + } + }); + setPermissions(perms); + setMine(resp.data.mine.settings); + setDevices(data.devices); + let newBlocks: Placeable[] = []; + if (resp.data.mine?.settings?.placeables) + resp.data.mine?.settings?.placeables.forEach(p => { + let placeable = loadPlaceable(p); + newBlocks.push(placeable as Placeable); + }); + setBlocks(newBlocks); + let prefs = new Map(Object.entries(data.componentPreferences)); + setComponentPreferences(prefs); + setComponentDevices(new Map(Object.entries(data.componentDevices))); + let components: Component[] = []; + if (resp.data.components) + resp.data.components.forEach(component => { + components.push(Component.any(component)); + }); + setComponents(components); + } + }) + .finally(() => { + setLoading(false); + }); + // eslint-disable-next-line + }, [mineKey, mineAPI]); + + return ( + +
+ + { +
+ +
+ +
+
+ } +
+
+ ); +} diff --git a/src/providers/index.ts b/src/providers/index.ts index f170ff0..58b2e10 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -34,7 +34,7 @@ export { useUserAPI, // useStripeAPI, // useDataDogProxyAPI, - // useMineAPI, + useMineAPI, // useKeyManagerAPI, // useTerminalAPI, useGateAPI, diff --git a/src/providers/pond/mineAPI.tsx b/src/providers/pond/mineAPI.tsx new file mode 100644 index 0000000..305b3ec --- /dev/null +++ b/src/providers/pond/mineAPI.tsx @@ -0,0 +1,175 @@ +import { AxiosResponse } from "axios"; +import { useHTTP } from "hooks"; +import { permissionToString } from "pbHelpers/Permission"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers/StateContainer"; +import { createContext, PropsWithChildren, useContext } from "react"; +import { pondURL } from "./pond"; + +export interface IMineAPIContext { + addMine: (mine: pond.MineSettings) => Promise>; + getMine: (key: string) => Promise>; + addDevice: (key: string, deviceID: string, permissions: pond.Permission[]) => Promise; + addComponent: ( + key: string, + deviceID: string, + componentKey: string, + permissions: pond.Permission[] + ) => Promise; + listMines: ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + asRoot?: boolean + ) => Promise>; + listMinesSimple: ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + asRoot?: boolean + ) => Promise>; + removeMine: (key: string) => Promise>; + updateMine: ( + mine: pond.MineSettings, + componentPreferences: Map + ) => Promise>; +} + +export const MineAPIContext = createContext({} as IMineAPIContext); + +interface Props {} + +export default function MineProvider(props: PropsWithChildren) { + const { children } = props; + const { get, del, post, put } = useHTTP(); + const [{ as }] = useGlobalState(); + + const addMine = (mine: pond.MineSettings) => { + return post(pondURL("/mines" + (as ? "?as=" + as : "")), mine); + }; + + const getMine = (key: string) => { + return get(pondURL("/mines/" + key + (as ? "?as=" + as : ""))); + }; + + const listMines = ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + asRoot?: boolean, + keys?: string, + types?: string + ) => { + const url = pondURL( + "/mines" + + "?limit=" + + limit + + "&offset=" + + offset + + ("&order=" + (order ? order : "asc")) + + ("&by=" + (orderBy ? orderBy : "deviceId")) + + (search ? "&search=" + search : "") + + (asRoot ? "&asRoot=" + asRoot.toString() : "") + + (as ? "&as=" + as : "") + + (keys ? "&keys=" + keys : "") + + (types ? "&types=" + types : "") + ); + return get(url); + }; + + const listMinesSimple = ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + asRoot?: boolean + ) => { + const url = pondURL( + "/minesSimple" + + "?limit=" + + limit + + "&offset=" + + offset + + ("&order=" + (order ? order : "asc")) + + ("&by=" + (orderBy ? orderBy : "deviceId")) + + (search ? "&search=" + search : "") + + (asRoot ? "&asRoot=" + asRoot.toString() : "") + + (as ? "&as=" + as : "") + ); + return get(url); + }; + + const removeMine = (key: string) => { + return del(pondURL("/mines/" + key + (as ? "?as=" + as : ""))); + }; + + const updateMine = ( + mine: pond.MineSettings, + componentPreferences: Map + ) => { + let request = pond.UpdateMineRequest.create(); + let container = pond.MineComponentPreferencesContainer.create(); + request.settings = mine; + //if (componentPreferences) { + componentPreferences.forEach((pref, key) => { + container.preferences.push(pref); + container.keys.push(key); + }); + request.componentPreferences = container; + //} + return put(pondURL("/mines"), request); + }; + + const addDevice = (key: string, deviceID: string, permissions: pond.Permission[]) => { + if (as) + return post(pondURL(`/mines/` + key + `/addDevice/` + deviceID + `?as=${as}`), { + permissions: permissions.map(permission => permissionToString(permission)) + }); + return post(pondURL(`/mines/` + key + `/addDevice/` + deviceID), { + permissions: permissions.map(permission => permissionToString(permission)) + }); + }; + + const addComponent = ( + key: string, + deviceID: string, + componentKey: string, + permissions: pond.Permission[] + ) => { + if (as) + return post( + pondURL(`/mines/` + key + `/addComponent/` + deviceID + "/" + componentKey + `?as=${as}`), + { + permissions: permissions.map(permission => permissionToString(permission)) + } + ); + return post(pondURL(`/mines/` + key + `/addComponent/` + deviceID + "/" + componentKey), { + permissions: permissions.map(permission => permissionToString(permission)) + }); + }; + + return ( + + {children} + + ); +} + +export const useMineAPI = () => useContext(MineAPIContext); diff --git a/src/providers/pond/pond.tsx b/src/providers/pond/pond.tsx index 7bcdc68..0cef572 100644 --- a/src/providers/pond/pond.tsx +++ b/src/providers/pond/pond.tsx @@ -20,6 +20,7 @@ import GrainBagProvider, { useGrainBagAPI } from "./grainBagAPI"; import FieldProvider, { useFieldAPI } from "./fieldAPI"; import TransactionProvider, { useTransactionAPI } from "./transactionAPI"; import FileControllerProvider, { useFileControllerAPI } from "./fileControllerAPI"; +import MineProvider, { useMineAPI } from "./mineAPI"; // import NoteProvider from "providers/noteAPI"; export const pondURL = (partial: string, demo: boolean = false): string => { @@ -56,7 +57,9 @@ export default function PondProvider(props: PropsWithChildren) { - {children} + + {children} + @@ -100,4 +103,5 @@ export { useFieldAPI, useTransactionAPI, useFileControllerAPI, + useMineAPI, }; diff --git a/src/ventilation/AddMine.tsx b/src/ventilation/AddMine.tsx new file mode 100644 index 0000000..aec2c07 --- /dev/null +++ b/src/ventilation/AddMine.tsx @@ -0,0 +1,53 @@ +import { + Button, + DialogActions, + DialogContent, + DialogTitle, + TextField +} from "@mui/material"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { useMineAPI } from "hooks"; +import { pond } from "protobuf-ts/pond"; +import { useState } from "react"; + +interface Props { + open: boolean; + closeCallback: () => void; + refreshCallback: () => void; +} + +export default function AddMine(props: Props) { + const { open, closeCallback, refreshCallback } = props; + const [name, setName] = useState(""); + const mineAPI = useMineAPI(); + + const addMine = () => { + let newMine = pond.MineSettings.create(); + newMine.name = name; + mineAPI.addMine(newMine).finally(() => { + closeCallback(); + refreshCallback(); + }); + }; + + return ( + + Create new Mine + + setName(e.target.value)} + /> + + + + + + + ); +} diff --git a/src/ventilation/Canvas.tsx b/src/ventilation/Canvas.tsx new file mode 100644 index 0000000..35cf7c4 --- /dev/null +++ b/src/ventilation/Canvas.tsx @@ -0,0 +1,51 @@ +import React, { useRef, FC } from "react"; +interface Props { + draw: any; + height: number | string; + width: number | string; + onMouseDown?: any; + onMouseUp?: any; + onMouseMove?: any; + onKeyDown?: any; + onKeyUp?: any; + onWheel?: any; + onContextMenu?: any; +} + +const Canvas: FC = ({ + draw, + height, + width, + onMouseDown, + onMouseUp, + onMouseMove, + onKeyDown, + onKeyUp, + onWheel, + onContextMenu +}) => { + const canvas = useRef(null); + + React.useEffect(() => { + const context = canvas.current?.getContext("2d"); + draw(context); + }); + + return ( + + ); +}; + +export default Canvas; diff --git a/src/ventilation/Drag.tsx b/src/ventilation/Drag.tsx new file mode 100644 index 0000000..565afaa --- /dev/null +++ b/src/ventilation/Drag.tsx @@ -0,0 +1,58 @@ +import React, { useState } from "react"; +import Draggable, { DraggableEvent, PositionOffsetControlPosition } from "react-draggable"; +import pipeIcon from "../assets/components/pipeT.png"; + +interface Props { + onStart?: (event: DraggableEvent) => void; + onStop?: (event: DraggableEvent) => void; + offsetParent?: HTMLElement | Element | undefined; + positionOffset?: PositionOffsetControlPosition | undefined; + children?: any; + transform?: string; + selected?: boolean; +} + +export default function Drag(props: Props) { + const { onStart, onStop, children, selected } = props; + const [pointerEvents, setPointerEvents] = useState(true); + + const myOnStart = (event: DraggableEvent) => { + if (onStart) onStart(event); + setPointerEvents(false); + }; + + const myOnStop = (event: DraggableEvent) => { + if (onStop) onStop(event); + setPointerEvents(true); + }; + + const onDrag = (event: DraggableEvent) => { + console.log("drag"); + }; + + const dragHandlers = { onStart: myOnStart, onStop: myOnStop }; + return ( + +
+ pipe +
+
+ ); +} diff --git a/src/ventilation/Editor.tsx b/src/ventilation/Editor.tsx new file mode 100644 index 0000000..d8174e2 --- /dev/null +++ b/src/ventilation/Editor.tsx @@ -0,0 +1,860 @@ +import React, { useEffect, useState } from "react"; +import Canvas from "ventilation/Canvas"; +import Zoom from "ventilation/Zoom"; +import { pond } from "protobuf-ts/pond"; +import { + CircularProgress, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + ToggleButton, + Typography, + useTheme +} from "@mui/material"; +import { Info, AddCircle, InfoRounded } from "@mui/icons-material"; +import { VentShaft } from "./drawable/VentShaft"; +import { VentCorner } from "./drawable/VentCorner"; +import { Placeable } from "./drawable/Placeable"; +import VentDialog from "./VentDialog"; +import DeleteIcon from "@mui/icons-material/DeleteForever"; +import ClockwiseIcon from "@mui/icons-material/RotateRight"; +import CounterclockwiseIcon from "@mui/icons-material/RotateLeft"; +import { useGlobalState, useMineAPI } from "providers"; +import { Component, Device } from "models"; +import { Sensor } from "./drawable/Sensor"; +// import { ToggleButton } from "@material-ui/lab"; +import { VentTBreak } from "./drawable/VentTBreak"; +import InteractionSettings from "interactions/InteractionSettings"; +import { UnitMeasurement } from "models/UnitMeasurement"; + +interface Props { + width?: number; + height?: number; + mode?: Mode; + undoRef: any; + redoRef: any; + saveRef: any; + addBlockRef: any; + devices: pond.Device[]; + blocks: Placeable[]; + setBlocks: React.Dispatch>; + components: Component[]; + setComponents: React.Dispatch>; + componentPreferences: Map; + setComponentPreferences: React.Dispatch< + React.SetStateAction> + >; + componentDevices: Map; + sensors: Sensor[]; + setSensors: React.Dispatch>; + loading: boolean; +} + +enum Mode { + NORMAL, + MOVE, + ADD, + DELETE +} + +enum ViewMode { + NORMAL, + INFO +} + +export default function Editor(props: Props) { + const { + blocks, + setBlocks, + devices, + components, + componentDevices, + componentPreferences, + sensors, + setSensors, + setComponentPreferences, + loading + } = props; + const [selected, setSelected] = useState([]); + const [width, setWidth] = useState(props.width ? props.width : 800); + const [height, setHeight] = useState(props.width ? props.width : 600); + const [cameraOffsetX, setCameraOffsetX] = useState(0); + const [cameraOffsetY, setCameraOffsetY] = useState(0); + const [clickX, setClickX] = useState(0); + const [clickY, setClickY] = useState(0); + const [mode, setMode] = useState(props.mode ? props.mode : Mode.NORMAL); + const [viewMode, setViewMode] = useState(ViewMode.NORMAL); + const [mouseDown, setMouseDown] = useState(false); + const [undoStates, setUndoStates] = useState([]); + const [redoStates, setRedoStates] = useState([]); + const [ctrl, setCtrl] = useState(false); + const [moveOrigin, setMoveOrigin] = useState([]); + const [mousePosX, setMousePosX] = useState(0); + const [mousePosY, setMousePosY] = useState(0); + const [hoveredBlock, setHoveredBlock] = useState(undefined); + const [placementBlock, setPlacementBlock] = useState(undefined); + const [anchorEl, setAnchorEl] = React.useState(null); + const [anchorPosX, setAnchorPosX] = useState(0); + const [anchorPosY, setAnchorPosY] = useState(0); + const [ventType, setVentType] = useState(undefined); + //const [placeableType, setPlaceableType] = useState(undefined); + const [direction, setDirection] = useState(0); + const [scale, setScale] = useState(1); + const [interactionDevice, setInteractionDevice] = useState(); + const [interactionComponent, setInteractionComponent] = useState(); + const [interactionSensor, setInteractionSensor] = useState(); + const [isAddInteractionOpen, setIsAddInteractionOpen] = useState(false); + const mineAPI = useMineAPI(); + const theme = useTheme(); + + const [componentMeasurements, setComponentMeasurements] = useState< + undefined | Map + >(undefined); + + const [{ user }] = useGlobalState(); + + const save = () => { + let settings = pond.MineSettings.create(); + props.devices.forEach(device => { + if (device.settings) settings.devices.push(device.settings?.deviceId); + }); + mineAPI.addMine(settings).then((resp: any) => { + console.log(resp); + }); + }; + + useEffect(() => { + setWidth(props.width ? props.width : 800); + setHeight(props.height ? props.height : 600); + }, [props.width, props.height]); + + useEffect(() => { + setMode(props.mode ? props.mode : Mode.NORMAL); + }, [props.mode]); + + useEffect(() => { + if (selected.length > 0) { + selected[0].setDirection(direction); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [direction]); + + useEffect(() => { + let compMeasurements: Map = new Map(); + components.forEach(comp => { + let measurements: UnitMeasurement[] = []; + comp.lastMeasurement.forEach(m => { + let um = UnitMeasurement.create(m, user); + measurements.push(um); + }); + compMeasurements.set(comp.key(), measurements); + }); + setComponentMeasurements(compMeasurements); + }, [components, user]); + + useEffect(() => { + if ( + componentMeasurements === undefined || + componentPreferences.size < 1 || + components.length < 1 || + !user || + componentDevices.size < 1 || + componentMeasurements.size < 1 + ) + return; + let sensors: Sensor[] = []; + components.forEach(comp => { + if (componentPreferences.get(comp.key())) { + let p = componentPreferences.get(comp.key()); + if (p?.sensors) + p?.sensors.forEach((s, i) => { + if (s.enabled) { + let id = componentDevices.get(comp.key()); + let device = devices.find(dev => dev.settings?.deviceId === id); + let measurements = componentMeasurements.get(comp.key()); + if (measurements !== undefined) + sensors.push(Sensor.create(comp, Device.create(device), s, measurements, i, user)); + } + }); + } + }); + setSensors(sensors); + }, [ + components, + componentPreferences, + setSensors, + user, + devices, + componentDevices, + componentMeasurements + ]); + + const getOffsetX = (e: any): number => { + if (e.offsetParent) { + return e.offsetLeft + getOffsetX(e.offsetParent); + } + return e.offsetLeft; + }; + + const getAbsoluteX = (e: any): number => { + return e.pageX - getOffsetX(e.currentTarget); + }; + + const getX = (e: any): number => { + let offset = getOffsetX(e.currentTarget); + offset = offset - cameraOffsetX * scale; + return (e.pageX - offset) / scale; + }; + + const getOffsetY = (e: any): number => { + if (e.offsetParent) { + return e.offsetTop + getOffsetY(e.offsetParent); + } + return e.offsetTop; + }; + + const getAbsoluteY = (e: any): number => { + return e.pageY - getOffsetY(e.currentTarget); + }; + + const getY = (e: any): number => { + let offset = getOffsetY(e.currentTarget); + offset = offset - cameraOffsetY * scale; + //offset = offset + mouseOffsetY; + return (e.pageY - offset) / scale; + }; + + const addPlaceable = (p: Placeable) => { + let r = blocks; + saveState(); + if (selected.length > 0) { + let xy = selected[0].getEndXAndY(); + p.y = xy.y; + p.x = xy.x; + p.setAngle(selected[0].getEndAngle(), selected[0].getEndAngle()); + } else { + p.x = clickX; + p.y = clickY; + } + selected[0] = p; + r.push(p); + setBlocks([...r]); + }; + + useEffect(() => { + props.addBlockRef.current = addPlaceable; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected, addPlaceable, blocks]); + + const add = (x?: number, y?: number) => { + let r = blocks; + saveState(); + let p = VentShaft.create(); + if (x && y) { + let v = VentCorner.create(x, y, 32, 0); + setBlocks([...r]); + return v; + } else if (Boolean(anchorEl)) { + p.x = anchorPosX + cameraOffsetX; + p.y = anchorPosY + cameraOffsetY; + p.angle = 0; + } else { + p.x = p.x + cameraOffsetX; + p.y = p.y + cameraOffsetY; + } + r.push(p); + setBlocks([...r]); + //return p; + }; + + const saveState = () => { + let u = undoStates; + let newBlocks: Placeable[] = []; + blocks.forEach(block => { + let b = block.clone(); + newBlocks.push(b); + }); + u.push(newBlocks); + setRedoStates([]); + }; + + const click = (e: any) => { + if (hoveredBlock !== undefined) { + setMode(hoveredBlock); + setMouseDown(false); + return; + } + selected.forEach(s => { + s.selected = false; + }); + let hit = false; + let r = blocks; + let x = getX(e); + let y = getY(e); + setClickX(x); + setClickY(y); + let newBlocks: Placeable[] = []; + if (mode === Mode.NORMAL) { + r.forEach(block => { + newBlocks.push(block.clone()); + if (block.clickCheck(x, y, scale)) { + hit = true; + setSelected([block]); + } + }); + sensors.forEach(sensor => { + if (sensor.clickCheck(x, y, scale)) { + hit = true; + setSelected([sensor]); + } + }); + } + + if (!hit) setSelected([]); + + if (e.button === 2) return; + + if (hit) setMoveOrigin(newBlocks); + + if (mode === Mode.ADD) { + add(); + } else if (mode === Mode.DELETE) { + deleteBlock(x, y, true); + } + setMouseDown(true); + }; + + const mouseUp = (e: any) => { + setMouseDown(false); + if (mode === Mode.NORMAL && selected !== undefined) { + let state = [...moveOrigin]; + let states = [...undoStates]; + states.push(state); + setUndoStates([...states]); + setRedoStates([]); + selected.forEach(placeable => { + if (placeable.type === pond.PlaceableType.PLACEABLE_TYPE_SENSOR) { + let s = placeable as Sensor; + let prefs = componentPreferences.get(s.getComponent().key()); + if (prefs?.sensors[s.index]) { + prefs.sensors[s.index].x = s.x; + prefs.sensors[s.index].y = s.y; + componentPreferences.set(s.getComponent().key(), prefs); + setComponentPreferences(new Map(componentPreferences)); + } + } + }); + } + //setSelected(undefined); + }; + + const keyDown = (e: any) => { + if (e.key === "1") { + setMode(Mode.NORMAL); + } else if (e.key === "2") { + setMode(Mode.MOVE); + } else if (e.key === "3") { + setMode(Mode.ADD); + } else if (e.key === "4") { + setMode(Mode.DELETE); + } else if (e.key === "Control") { + setCtrl(true); + } else if (e.key === "z" && ctrl) { + undo(); + } else if (e.key === "Z" && ctrl) { + redo(); + } else if (e.key === "s" && ctrl) { + save(); + } else if (e.key === "v") { + if (viewMode === ViewMode.NORMAL) { + setViewMode(ViewMode.INFO); + } else { + setViewMode(ViewMode.NORMAL); + } + } + }; + + const keyUp = (e: any) => { + if (e.key === "Control") { + setCtrl(false); + } + }; + + const deleteBlock = (x: number, y: number, save?: boolean) => { + if (save) saveState(); + let b = [...blocks]; + blocks.forEach((block, index) => { + if (block.clickCheck(x + cameraOffsetX, y + cameraOffsetY, scale)) { + b.splice(index, 1); + return; + } + }); + setBlocks(b); + }; + + const drag = (e: any) => { + setHoveredBlock(undefined); + let x = getAbsoluteX(e); + let y = getAbsoluteY(e); + setMousePosX(x); + setMousePosY(y); + + if (!mouseDown) { + // Set position for placement block + if (mode === Mode.ADD && placementBlock) { + let p = placementBlock.clone() as Placeable; + p.x = x; // - p.width() / 2; + p.y = y; // - p.height() / 2; + setPlacementBlock(p); + } + } else { + // If the mouse is clicked down + if (mode === Mode.MOVE) { + setCameraOffsetX(clickX - x / scale); + setCameraOffsetY(clickY - y / scale); + } else if (selected.length > 0 && mode === Mode.NORMAL) { + selected[0].drag(getX(e), getY(e), scale); + blocks.forEach(block => { + if (block.x !== selected[0].x && block.y !== selected[0].y) { + selected[0].snap(block, 16); + } + }); + } else if (mode === Mode.DELETE) { + deleteBlock(x, y); + } + } + }; + + const redo = () => { + if (redoStates.length < 1) return; + let rects = redoStates.pop(); + let undos = undoStates; + if (rects) { + undos.push(rects); + setUndoStates([...undos]); + setBlocks(rects); + } + }; + + const undo = () => { + let states = undoStates; + let redo = redoStates; + let rects = states.pop(); + if (rects) { + redo.push([...rects]); + setRedoStates([...redo]); + setUndoStates([...states]); + setBlocks(rects); + } + }; + + useEffect(() => { + props.saveRef.current = save; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [save]); + + useEffect(() => { + props.undoRef.current = undo; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [undo]); + + useEffect(() => { + props.redoRef.current = redo; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [redo]); + + const wheel = (e: WheelEvent) => { + // Scroll down + if (e.deltaY > 0) { + let newScale = scale - 0.05; + if (newScale > 0) { + let mouseRatioX = mousePosX / width; + let mouseRatioY = mousePosY / height; + let newWidth = width / newScale; + let dx = (width - newWidth) * mouseRatioX; + let olddx = (width - width / scale) * mouseRatioX; + let newHeight = height / newScale; + let dy = (height - newHeight) * mouseRatioY; + let olddy = (height - height / scale) * mouseRatioY; + setScale(Math.round(newScale * 1000) / 1000); + setCameraOffsetX(cameraOffsetX - olddx + dx); + setCameraOffsetY(cameraOffsetY - olddy + dy); + } + } + // Scroll up + if (e.deltaY < 0) { + let mouseRatioX = mousePosX / width; + let mouseRatioY = mousePosY / height; + let newScale = scale + 0.05; + let newWidth = width / newScale; + let dx = (width - newWidth) * mouseRatioX; + let olddx = (width - width / scale) * mouseRatioX; + let newHeight = height / newScale; + let dy = (height - newHeight) * mouseRatioY; + let olddy = (height - height / scale) * mouseRatioY; + setScale(Math.round(newScale * 1000) / 1000); + setCameraOffsetX(cameraOffsetX - olddx + dx); + setCameraOffsetY(cameraOffsetY - olddy + dy); + } + // If the mouse is at the top, scroll mode selection + /*if (mousePosY < 100) { + // Scroll down + if (e.deltaY > 0) { + //if (mode === Mode.NORMAL) setMode(Mode.DELETE); + //else setMode(mode - 1); + + console.log("scroll down") + let newScale = scale - 0.1 + setScale(newScale) + + } + // Scroll up + if (e.deltaY < 0) { + //if (mode === Mode.DELETE) setMode(Mode.NORMAL); + //else setMode(mode + 1); + + console.log("scroll up") + let newScale = scale + 0.1 + setScale(newScale) + + } + }*/ + }; + + const drawBlocks = (context: CanvasRenderingContext2D) => { + blocks.forEach((block, index) => { + if (block.draw) block.draw(context, cameraOffsetX, cameraOffsetY, scale); + }); + selected.forEach(block => { + block.highlight(context, cameraOffsetX, cameraOffsetY, scale); + }); + }; + + const drawSensors = (context: CanvasRenderingContext2D) => { + /*components.forEach(comp => { + let x = or(componentPreferences.get(comp.key())?.x, 10) + let y = or(componentPreferences.get(comp.key())?.y, 10) + context.arc(x - cameraOffsetX, y - cameraOffsetY, 25, 2 * Math.PI, 0, false) + context.fill() + })*/ + sensors.forEach(sensor => { + sensor.draw(context, cameraOffsetX, cameraOffsetY, scale); + if (viewMode === ViewMode.INFO) + sensor.highlight(context, cameraOffsetX, cameraOffsetY, scale); + }); + }; + + const draw = (context: CanvasRenderingContext2D) => { + context.clearRect(0, 0, width, height); + context.fillStyle = "rgba(150, 150, 255, 150)"; + context.strokeStyle = "rgba(247, 202, 24, 1)"; + + drawBlocks(context); + drawSensors(context); + + // Draw placement block if in add mode + if (mode === Mode.ADD) { + context.globalAlpha = 0.3; + if (placementBlock) placementBlock.draw(context); + context.globalAlpha = 1; + } + + // Draw garbage can if in delete mode + if (mode === Mode.DELETE) { + let i = new Image(); + i.src = "https://img.icons8.com/material-outlined/48/000000/delete-forever.png"; + context.globalAlpha = 1; + context.drawImage(i, mousePosX - 16, mousePosY - 16, 32, 32); + } + }; + + const contextMenu = (e: Event) => { + e.preventDefault(); + setAnchorPosX(getAbsoluteX(e)); + setAnchorPosY(getAbsoluteY(e)); + if (!anchorEl) setAnchorEl(e.currentTarget as any); + }; + + /*const isTBreak = (p: Placeable) => { + if (p.type === pond.PlaceableType.PLACEABLE_TYPE_SHAFT) { + if (p.subtype === pond.ShaftType.SHAFT_TYPE_T_BREAK) { + return true; + } + } + return false; + };*/ + + const isDefaultShaft = (p: Placeable) => { + if (p.type === pond.PlaceableType.PLACEABLE_TYPE_SHAFT) { + if (p.subtype === pond.ShaftType.SHAFT_TYPE_DEFAULT) { + return true; + } + } + return false; + }; + + return ( +
+
+
+ +
+ {loading && ( +
+ + Loading... +
+ )} +
+ { + if (viewMode === ViewMode.INFO) { + setViewMode(ViewMode.NORMAL); + } else { + setViewMode(ViewMode.INFO); + } + }}> + + +
+ +
+ + {interactionDevice && interactionComponent && ( + setIsAddInteractionOpen(false)} + refreshCallback={() => {}} + canEdit={true} + sensor={interactionSensor} + /> + )} + setAnchorEl(null)} + // getContentAnchorEl={null} + anchorOrigin={{ vertical: anchorPosY, horizontal: anchorPosX }} + keepMounted + onContextMenu={(e: any) => { + e.preventDefault(); + setAnchorEl(null); + }} + disableAutoFocusItem> + {selected.length > 0 && selected[0].type === pond.PlaceableType.PLACEABLE_TYPE_SHAFT && ( + { + setAnchorEl(null); + setVentType(selected[0].subtype); + }} + > + + + + + + )} + {selected.length > 0 && selected[0].type === pond.PlaceableType.PLACEABLE_TYPE_SENSOR && ( + { + let s = selected[0] as Sensor; + setAnchorEl(null); + //setVentType(selected[0].subtype); + setInteractionComponent(s.getComponent()); + setInteractionDevice(s.getDevice()); + setInteractionSensor(s); + setIsAddInteractionOpen(true); + }} + > + + + + + + )} + {!(selected.length > 0 && isDefaultShaft(selected[0])) && ( + { + setAnchorEl(null); + let v = VentShaft.create(); + addPlaceable(v); + }} + > + + + + + + )} + { + setAnchorEl(null); + let v = VentCorner.create(); + addPlaceable(v); + }} + > + + + + + + { + let v = VentTBreak.create(); + addPlaceable(v); + setAnchorEl(null); + }} + > + + + + + + {selected.length > 0 && + (selected[0].subtype === pond.ShaftType.SHAFT_TYPE_CORNER || + selected[0].subtype === pond.ShaftType.SHAFT_TYPE_T_BREAK) && ( + { + selected[0].setDirection(0); + setAnchorEl(null); + }} + > + + + + + + )} + {selected.length > 0 && + (selected[0].subtype === pond.ShaftType.SHAFT_TYPE_CORNER || + selected[0].subtype === pond.ShaftType.SHAFT_TYPE_T_BREAK) && ( + { + selected[0].setDirection(1); + setAnchorEl(null); + }} + > + + + + + + )} + {selected.length > 0 && ( + { + saveState(); + let b = [...blocks]; + blocks.forEach((block, index) => { + if ( + selected[0].x === block.x && + selected[0].y === block.y && + block.angle === selected[0].angle && + block.type === selected[0].type + ) { + b.splice(index, 1); + return; + } + }); + setAnchorEl(null); + setSelected([]); + setBlocks(b); + }} + > + + + + + + )} + {selected.length > 0 && false && ( + { + setAnchorEl(null); + let xy = selected[0].getEndXAndY(); + let x = xy.x; + let y = xy.y; + //let startBlock = selected[0].clone(); + let block = add(x, y); + if (block) { + //block.circle.selected = true; + //block.startSnap = startBlock; + //setSelected([block]); + } + //setMouseDown(true); + }} + > + + + + + + )} + +
+ ); +} diff --git a/src/ventilation/EditorActions.tsx b/src/ventilation/EditorActions.tsx new file mode 100644 index 0000000..609a002 --- /dev/null +++ b/src/ventilation/EditorActions.tsx @@ -0,0 +1,116 @@ +import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from "@material-ui/core"; +import { AccountCircle, MoreVert, Settings } from "@material-ui/icons"; +import { isOffline } from "utils/environment"; +import { mineScope } from "models"; +import { pond } from "protobuf-ts/pond"; +import React, { useState } from "react"; +import ObjectUsers from "user/ObjectUsers"; +import { or } from "utils"; +import EditorSettings from "./EditorSettings"; +import ObjectTeams from "teams/ObjectTeams"; +import ObjectTeamsIcon from "@material-ui/icons/SupervisedUserCircle"; +import { useGlobalState } from "providers"; + +interface Props { + mine: pond.MineSettings; + setMine: React.Dispatch>; + permissions: any; + refreshCallback: () => void; +} + +export default function EditorActions(props: Props) { + const { mine, setMine, permissions, refreshCallback } = props; + const [settingsDialog, setSettingsDialog] = useState(false); + const [userDialog, setUserDialog] = useState(false); + const [teamDialog, setTeamDialog] = useState(false); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [{ user }] = useGlobalState(); + + const menu = () => { + const canManageUsers = permissions.includes(pond.Permission.PERMISSION_USERS); + const canManageTeams = canManageUsers && user.hasFeature("teams"); + return ( + setMenuAnchorEl(null)} + disableAutoFocusItem> + {/* Users menu item */} + {!isOffline() && canManageUsers && ( + setUserDialog(true)} button dense> + + + + + + )} + {!isOffline() && canManageTeams && ( + setTeamDialog(true)} button dense> + + + + + + )} + + ); + }; + + const users = () => { + return ( + setUserDialog(false)} + refreshCallback={refreshCallback} + /> + ); + }; + + const buttons = () => { + return ( + + + setSettingsDialog(true)}> + + + + + + setMenuAnchorEl(event.currentTarget)}> + + + + + + ); + }; + + return ( + + {buttons()} + {menu()} + {users()} + + setTeamDialog(false)} + /> + + ); +} diff --git a/src/ventilation/EditorDrawer.tsx b/src/ventilation/EditorDrawer.tsx new file mode 100644 index 0000000..4946d52 --- /dev/null +++ b/src/ventilation/EditorDrawer.tsx @@ -0,0 +1,43 @@ +import { createStyles, makeStyles, Theme } from "@material-ui/core"; +import React from "react"; + +const drawer = { + "&": { + bottom: 0, + margin: 0, + //padding: 0, + //maxHeight: "auto", + backgroundColor: "rgba(35, 35, 35, 1)", + overflow: "hidden", + overflowY: "auto" + } +}; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + drawer: { + ...drawer, + height: "calc(100% - " + theme.spacing(7) + "px)", + minWidth: theme.spacing(42), + padding: theme.spacing(2) + }, + closed: { + ...drawer, + height: "calc(100% - " + theme.spacing(7) + "px)", + width: theme.spacing(0) + } + }); +}); + +interface Props { + isOpen: boolean; + children: any; +} + +export default function EditorDrawer(props: Props) { + //const isMobile = useMobile(); + const { isOpen, children } = props; + const classes = useStyles(); + + return
{children}
; +} diff --git a/src/ventilation/EditorHeader.tsx b/src/ventilation/EditorHeader.tsx new file mode 100644 index 0000000..13dfba2 --- /dev/null +++ b/src/ventilation/EditorHeader.tsx @@ -0,0 +1,220 @@ +import { + createStyles, + Divider, + Grid, + IconButton, + makeStyles, + Theme, + Typography, + useTheme +} from "@material-ui/core"; +import React, { useState } from "react"; +import UndoIcon from "assets/editor/undo.png"; +import RedoIcon from "assets/editor/redo.png"; +import MoveIcon from "assets/editor/move.png"; +import CursorIcon from "assets/editor/cursor.png"; +import DeleteIcon from "assets/editor/delete.png"; +import { ImgIcon } from "common/ImgIcon"; +import { pond } from "protobuf-ts/pond"; +import { useMineAPI, useSnackbar } from "providers"; +import { Save, CloudDownload } from "@material-ui/icons"; +import { Placeable } from "./drawable/Placeable"; +import { Component } from "models"; +import EditorActions from "./EditorActions"; +import LoadMine from "./LoadMine"; +import { Sensor } from "./drawable/Sensor"; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + header: { + width: "100%", + height: theme.spacing(7), + background: "rgba(25, 25, 25, 1)", + margin: 0, + padding: 0, + display: "flex", + alignItems: "center", + borderBottom: "1px solid rgb(75, 75, 75)" + }, + middleButtons: { + display: "flex", + alignItems: "center", + margin: "auto" + }, + leftButtons: { + display: "flex", + alignItems: "left", + marginLeft: theme.spacing(1), + marginTop: "auto", + marginBottom: "auto" + }, + rightButtons: { + display: "flex", + alignItems: "right", + justifyItems: "flex-en", + marginRight: theme.spacing(1), + marginLeft: "auto" + }, + divider: { + height: "42px", + width: "2px", + margin: theme.spacing(1) + } + }); +}); + +enum Mode { + NORMAL, + MOVE, + ADD, + DELETE +} + +interface Props { + setMode: any; + undoRef: any; + redoRef: any; + saveRef: any; + blocks: Placeable[]; + devices: pond.Device[]; + components: Component[]; + setBlocks: React.Dispatch>; + setDevices: React.Dispatch>; + mine: pond.MineSettings; + setMine: React.Dispatch>; + permissions: pond.Permission[]; + setPermissions: React.Dispatch>; + componentPreferences: Map; + sensors: Sensor[]; +} + +export default function EditorHeader(props: Props) { + const classes = useStyles(); + const { + setMode, + undoRef, + redoRef, + blocks, + setBlocks, + setDevices, + mine, + setMine, + permissions, + setPermissions, + componentPreferences + } = props; + + const mineAPI = useMineAPI(); + const snackbar = useSnackbar(); + const theme = useTheme(); + const [loadDialog, setLoadDialog] = useState(false); + + const save = () => { + let settings = pond.MineSettings.create(); + blocks.forEach(block => { + let shaft = pond.Placeable.create(); + shaft.angle = block.angle; + shaft.direction = block.direction; + shaft.radius = block.radius(); + shaft.type = block.type.valueOf(); + shaft.subtype = block.subtype; + shaft.width = 64; + shaft.x = block.x; + shaft.y = block.y; + shaft.magnitude = block.magnitude(); + settings.placeables.push(shaft); + }); + + settings.name = mine.name; + + if (mine.key) { + settings.key = mine.key; + let prefs = componentPreferences; + mineAPI + .updateMine(settings, prefs) + .then(resp => { + if (resp.status === 200) { + snackbar.success("Changes successfully saved!"); + } + }) + .catch(err => { + snackbar.error("Error saving changes"); + }); + } else { + mineAPI + .addMine(settings) + .then(resp => { + if (resp.status === 200) { + snackbar.success("New mine successfully saved!"); + settings.key = resp.data.key; + setMine(settings); + setPermissions([ + pond.Permission.PERMISSION_READ, + pond.Permission.PERMISSION_SHARE, + pond.Permission.PERMISSION_USERS, + pond.Permission.PERMISSION_WRITE + ]); + } + }) + .catch(err => { + snackbar.error("Error saving new mine"); + }); + } + }; + + return ( + + + + {mine.name ? mine.name : "No Name"} + + + + + setLoadDialog(true)}> + + + + +
+ + + + + + + + + + setMode(Mode.NORMAL)}> + + + setMode(Mode.MOVE)}> + + + setMode(Mode.DELETE)}> + + +
+
+ +
+ {}} + permissions={permissions} + /> +
+
+ +
+ ); +} diff --git a/src/ventilation/EditorSettings.tsx b/src/ventilation/EditorSettings.tsx new file mode 100644 index 0000000..6445664 --- /dev/null +++ b/src/ventilation/EditorSettings.tsx @@ -0,0 +1,130 @@ +import { + Button, + createStyles, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, + TextField, + Theme, + Typography, + Tooltip +} from "@material-ui/core"; +import DeleteButton from "common/DeleteButton"; +import { cloneDeep } from "lodash"; +import { pond } from "protobuf-ts/pond"; +import { useMineAPI, useSnackbar } from "providers"; +import React, { useState } from "react"; +import { useHistory } from "react-router"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + dialogContent: { + minHeight: "25vh" + }, + removeDialog: { + zIndex: theme.zIndex.modal + 1 + } + }) +); + +interface Props { + open: boolean; + setOpen: React.Dispatch>; + mine: pond.MineSettings; + setMine: React.Dispatch>; +} + +export default function EditorSettings(props: Props) { + const { open, setOpen, mine, setMine } = props; + const [nameInput, setNameInput] = useState(""); + const [removeDialog, setRemoveDialog] = useState(false); + + const classes = useStyles(); + const mineAPI = useMineAPI(); + const snackbar = useSnackbar(); + const history = useHistory(); + + const updateName = (event: React.ChangeEvent) => { + setNameInput(event.currentTarget.value); + }; + + const apply = () => { + let m = cloneDeep(mine); + m.name = nameInput; + setMine(m); + setOpen(false); + }; + + const remove = () => { + mineAPI + .removeMine(mine.key) + .then(resp => { + if (resp.status === 200) snackbar.success("Mine successfully removed"); + setRemoveDialog(false); + setOpen(false); + setMine(pond.MineSettings.create()); + history.push("/mines"); + }) + .catch(err => { + snackbar.error("Could not delete the mine"); + }); + }; + + return ( + + setOpen(false)}> + Mine Settings + + updateName(e)} /> + + + + + setRemoveDialog(true)} + disabled={mine.key === undefined || mine.key === ""}> + Delete + + + + + + + + setRemoveDialog(false)} + aria-labelledby="confirm-remove-device-label" + aria-describedby="confirm-remove-device-description" + className={classes.removeDialog}> + Delete {mine.name}? + + + WARNING: + + + Clicking 'Accept' will remove the device from all users and groups associated with it. + + + + + + + + + ); +} diff --git a/src/ventilation/EditorSidebar.tsx b/src/ventilation/EditorSidebar.tsx new file mode 100644 index 0000000..23234ee --- /dev/null +++ b/src/ventilation/EditorSidebar.tsx @@ -0,0 +1,245 @@ +import { createStyles, makeStyles, Theme, Tooltip } from "@material-ui/core"; +import React, { useState } from "react"; +import FansIcon from "assets/editor/fans.png"; +import FansYellowIcon from "assets/editor/fansYellow.png"; +import DevicesIcon from "assets/editor/devices.png"; +import DuctingIcon from "assets/editor/ducting.png"; +import SensorsIcon from "assets/editor/sensors.png"; +import SensorsYellowIcon from "assets/editor/sensorsYellow.png"; +import DuctingYellowIcon from "assets/editor/ductingYellow.png"; +import EditorDrawer from "./EditorDrawer"; +import FansDrawer from "./drawers/FansDrawer"; +import DuctingDrawer from "./drawers/DuctingDrawer"; +import DeviceDrawer from "./drawers/DeviceDrawer"; +import { pond } from "protobuf-ts/pond"; +import SensorDrawer from "./drawers/SensorDrawer"; +import { Component } from "models"; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + sidebar: { + maxHeight: "auto", + width: theme.spacing(10), + background: "rgba(25, 25, 25, 1)", + margin: 0, + padding: 0 + }, + sideButtonInactive: { + width: theme.spacing(10), + height: theme.spacing(10), + padding: theme.spacing(2), + opacity: 0.4, + "&:hover": { + cursor: "default" + } + }, + sideButton: { + width: theme.spacing(10), + height: theme.spacing(10), + padding: theme.spacing(2), + "&:hover": { + background: "rgba(200,200,200,0.15)", + cursor: "pointer" + } + }, + sideButtonSelected: { + width: theme.spacing(10), + height: theme.spacing(10), + padding: theme.spacing(2), + zIndex: 40, + overflow: "visible", + background: "rgba(35, 35, 35, 1)", + //backgroundImage: + // "radial-gradient(circle at 38px 80px, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0) 40px, blue 40px)", + color: "yellow", + "&:hover": { + background: "rgba(200,200,200,0.15)" + }, + "&:before": { + width: "80px", + height: "80px", + borderRadius: "0 40px 40px 0", + backgroundColor: "#FFF", + display: "inline-block", + verticalAlign: "middle", + marginRight: "10px", + content: "" + } + }, + sideButtonImage: { + width: "100%", + height: "100%", + margin: 0, + padding: 0, + marginTop: theme.spacing(-1), + color: "yellow" + }, + sideButtonText: { + textAlign: "center", + margin: 0, + padding: 0, + marginTop: theme.spacing(-1) + } + }); +}); + +enum Selection { + NONE, + FANS, + DUCTING, + DEVICES, + SENSORS +} + +interface Props { + addBlockRef: any; + setDevices: any; + devices: pond.Device[]; + components: Component[]; + setComponents: React.Dispatch>; + mine: pond.MineSettings; + setMine: React.Dispatch>; + mineComponentPreferences: Map; + setMineComponentPreferences: React.Dispatch< + React.SetStateAction> + >; +} + +export default function EditorSidebar(props: Props) { + const { + addBlockRef, + setDevices, + devices, + setComponents, + mine, + setMine, + components, + mineComponentPreferences, + setMineComponentPreferences + } = props; + const classes = useStyles(); + const [selected, setSelected] = useState(Selection.NONE); + const [expanded, setExpanded] = useState(false); + + const select = (s: Selection) => { + if (s === selected) { + setSelected(Selection.NONE); + setExpanded(false); + } else { + setSelected(s); + setExpanded(true); + } + }; + + return ( + +
+
select(Selection.FANS)}> + {"Fan"} +
Fans
+
+
select(Selection.DUCTING)}> + {"Ducting"} +
Ducting
+
+ +
{ + if (mine.key.length < 1) return; + select(Selection.DEVICES); + }}> + {"Devices"} +
Devices
+
+
+ +
{ + if (mine.key.length < 1) return; + select(Selection.SENSORS); + }}> + {"Sensors"} +
Sensors
+
+
+
+ + + {selected === Selection.FANS ? ( + + ) : selected === Selection.DUCTING ? ( + + ) : selected === Selection.DEVICES ? ( + + ) : selected === Selection.SENSORS ? ( + + ) : ( + "Coming soon :)" + )} + +
+ ); +} diff --git a/src/ventilation/LoadMine.tsx b/src/ventilation/LoadMine.tsx new file mode 100644 index 0000000..f36143e --- /dev/null +++ b/src/ventilation/LoadMine.tsx @@ -0,0 +1,206 @@ +import { + Button, + Card, + CircularProgress, + createStyles, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + makeStyles, + MenuItem, + Select, + Theme, + Typography +} from "@material-ui/core"; +import { pond } from "protobuf-ts/pond"; +import { useMineAPI, useSnackbar } from "providers"; +import React, { useEffect, useState } from "react"; +import { or } from "utils"; +import { loadPlaceable, Placeable } from "./drawable/Placeable"; +import LeftIcon from "@material-ui/icons/KeyboardArrowLeft"; +import RightIcon from "@material-ui/icons/KeyboardArrowRight"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { useMobile } from "hooks"; + +interface Props { + setMine: (value: React.SetStateAction) => void; + setBlocks: (value: React.SetStateAction) => void; + setDevices: (value: React.SetStateAction) => void; + setPermissions: (value: React.SetStateAction) => void; + open: boolean; + setOpen: React.Dispatch>; +} + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + dialog: { + //width: theme.spacing(32) + }, + mineCard: { + margin: theme.spacing(1), + padding: theme.spacing(1), + background: theme.palette.background.default, + "&:hover": { + cursor: "pointer" + } + }, + highlight: { + backgroundColor: "rgba(255, 255, 255, 0)", + zIndex: 10, + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.1)" + } + }, + bottomButtons: { + marginLeft: "auto", + justifyContent: "center", + alignContent: "center" + }, + bottom: { + display: "flex", + alignItems: "center", + width: "100%", + paddingRight: theme.spacing(1), + paddingLeft: theme.spacing(1), + marginTop: theme.spacing(1) + } + }); +}); + +export default function LoadMine(props: Props) { + const { setMine, setBlocks, setDevices, setPermissions, open, setOpen } = props; + + const mineAPI = useMineAPI(); + const snackbar = useSnackbar(); + const [mines, setMines] = useState([]); + const [loading, setLoading] = useState(true); + const [limit, setLimit] = useState(5); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const classes = useStyles(); + const isMobile = useMobile(); + + const load = (key: string) => { + setLoading(true); + mineAPI + .getMine(key) + .then(resp => { + let newBlocks: Placeable[] = []; + if (resp.data.mine?.settings?.placeables) + resp.data.mine?.settings?.placeables.forEach(p => { + let placeable = loadPlaceable(p); + newBlocks.push(placeable as Placeable); + }); + setMine( + pond.MineSettings.fromObject(or(resp.data.mine?.settings, pond.MineSettings.create())) + ); + setBlocks(newBlocks); + setDevices(or(resp.data.devices, [])); + setMine(or(resp.data.mine?.settings, undefined)); + let d = pond.GetMineResponse.fromObject(resp.data); + setPermissions(d.permissions); + if (resp.status === 200) { + snackbar.success("Mine successfully loaded"); + } + }) + .catch(err => { + snackbar.error("Error loading mine"); + }) + .finally(() => { + setOpen(false); + setLoading(false); + }); + }; + + // Load list of mines when the tab is opened + useEffect(() => { + if (open) { + setLoading(true); + mineAPI.listMinesSimple(limit, offset).then(resp => { + setMines(resp.data.mines); + setLoading(false); + setTotal(resp.data.total); + }); + } + }, [mineAPI, open, limit, offset]); + + const back = () => { + if (offset - limit < 0) { + setOffset(0); + } else { + setOffset(offset - limit); + } + }; + + const forward = () => { + if (offset + limit > total) { + setOffset(total - limit); + } else { + setOffset(offset + limit); + } + }; + + const paginator = () => { + return ( +
+ + {offset + 1} - {offset + limit > total ? total : offset + limit} of {total} + +
+ + + + + + + +
+
+ ); + }; + + return ( + + setOpen(false)} + className={classes.dialog} + maxWidth="xs" + fullWidth + fullScreen={isMobile}> + Load a Mine + + {loading ? ( + + ) : mines.length < 1 ? ( + No mines + ) : ( + + {mines.map((mine, index) => { + return ( +
+ load(mine.key)}> + {mine.name === undefined ? "No Name" : mine.name} + +
+ ); + })} +
+ )} + {/* Set offsets */} + + {paginator()} +
+ + + +
+
+ ); +} diff --git a/src/ventilation/VentDialog.tsx b/src/ventilation/VentDialog.tsx new file mode 100644 index 0000000..322d2d9 --- /dev/null +++ b/src/ventilation/VentDialog.tsx @@ -0,0 +1,164 @@ +import React, { useState } from "react"; +import { + AppBar, + Toolbar, + Typography, + IconButton, + Grid, + Slider, + Divider, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, + InputLabel, + Select, + MenuItem, + useTheme +} from "@material-ui/core"; +import { Close } from "@material-ui/icons"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { useMobile } from "hooks"; +import { pond } from "protobuf-ts/pond"; + +interface Props { + type: pond.ShaftType | undefined; + setType: any; + setDirection: any; +} + +export default function VentDialog(props: Props) { + const { type, setType, setDirection } = props; + const [ventType, setVentType] = useState("Layflat"); + const [ventSubType, setVentSubType] = useState("Round"); + + const isMobile = useMobile(); + const theme = useTheme(); + + return ( + { + setType(undefined); + }}> + + + + Editing Vent Shaft + + setType(undefined)} + aria-label="close"> + + + + + {type === pond.ShaftType.SHAFT_TYPE_CORNER && ( + +
+ + Direction + + + } + label="Clockwise" + onClick={() => setDirection(0)} + /> + } + label="Counter Clockwise" + onClick={() => setDirection(1)} + /> + +
+ +
+ )} + + + + Specifications (coming soon) + + + Length: + + + Diameter: + + + + + + + Ventilation Type + + } + label="Layflat" + onClick={() => { + setVentType("Layflat"); + setVentSubType("Round"); + }} + /> + } + label="Rigid" + onClick={() => { + setVentType("Rigid"); + setVentSubType("Hardline"); + }} + /> + + + {ventType === "Layflat" ? ( + + Subtype + + + ) : ( + + Subtype + + + )} + +
+ ); +} diff --git a/src/ventilation/Zoom.tsx b/src/ventilation/Zoom.tsx new file mode 100644 index 0000000..cd76321 --- /dev/null +++ b/src/ventilation/Zoom.tsx @@ -0,0 +1,52 @@ +import { IconButton, InputAdornment, TextField, useTheme } from "@material-ui/core"; +import { Add, Remove } from "@material-ui/icons"; +import React from "react"; + +interface Props { + scale: number; + setScale: React.Dispatch>; +} + +export default function Zoom(props: Props) { + const { scale, setScale } = props; + const theme = useTheme(); + + const onTextInput = (e: React.ChangeEvent) => { + let value = parseInt(e.currentTarget.value); + if (value < 1000 && value > 0) { + setScale(value / 100); + } + }; + + const zoomOut = () => { + if (scale > 0.1) setScale(scale - 0.05); + }; + + const zoomIn = () => { + if (scale < 5) setScale(scale + 0.05); + }; + + return ( + + + + + + % + }} + onChange={e => onTextInput(e)} + /> + + + + + + ); +} diff --git a/src/ventilation/drawable/Block.ts b/src/ventilation/drawable/Block.ts new file mode 100644 index 0000000..c554069 --- /dev/null +++ b/src/ventilation/drawable/Block.ts @@ -0,0 +1,261 @@ +import StandardDuctIcon from "assets/mining/standardDuct.png"; +import ConnectionDuctIcon from "assets/mining/connectionDuct.png"; +import { pond } from "protobuf-ts/pond"; +import { cloneDeep } from "lodash"; +import { or } from "utils"; +import { Circle } from "./Circle"; + +export class Block { + public settings: pond.BlockSettings = pond.BlockSettings.create(); + public img: HTMLImageElement | undefined = undefined; + public selected = false; + public mouseOffsetX = 0; + public mouseOffsetY = 0; + public circle = Circle.create(0, 0, 0); + public startSnap: Block | undefined = undefined; + public endSnap: Block | undefined = undefined; + + public ctrl = false; + public shift = false; + + public static create(pb?: pond.Block, img?: HTMLImageElement): Block { + let my = new Block(); + my.img = new Image(); + if (pb) { + my.settings = pond.BlockSettings.fromObject(cloneDeep(or(pb.settings, {}))); + // For some reason, negative x and y values don't clone correctly + if (pb.settings) { + my.settings.x = pb.settings.x; + my.settings.y = pb.settings.y; + } + if (!img) { + switch (my.settings.type) { + case pond.BlockType.BLOCK_TYPE_VENT: + my.img.src = StandardDuctIcon; + break; + case pond.BlockType.BLOCK_TYPE_VENT_CORNER: + my.img.src = ConnectionDuctIcon; + break; + default: + my.img = undefined; + } + } else { + my.img = img; + } + } + my.ctrl = false; + my.shift = false; + return my; + } + + public static any(data: any): Block { + return Block.create(pond.Block.fromObject(cloneDeep(data))); + } + + public draw(context: CanvasRenderingContext2D, offsetX?: number, offsetY?: number) { + let x = this.settings.x; + let y = this.settings.y; + let width = this.settings.width; + let height = this.settings.height; + + if (this.img) { + switch (this.settings.type) { + case pond.BlockType.BLOCK_TYPE_NONE: + context.fillStyle = "rgba(150, 150, 255, 150)"; + context.fillRect(x - (offsetX ? offsetX : 0), y - (offsetY ? offsetY : 0), width, height); + break; + default: + context.fillStyle = "rgba(255, 255, 255, 255)"; + context.drawImage( + this.img, + x - (offsetX ? offsetX : 0), + y - (offsetY ? offsetY : 0), + width, + height + ); + } + } else { + context.fillStyle = "rgba(150, 150, 255, 150)"; + context.fillRect(x - (offsetX ? offsetX : 0), y - (offsetY ? offsetY : 0), width, height); + } + } + + public highlight(context: CanvasRenderingContext2D, offsetX?: number, offsetY?: number) { + let x = this.settings.x; + let y = this.settings.y; + let width = this.settings.width; + let height = this.settings.height; + + context.strokeStyle = "rgba(247, 202, 24, 1)"; + context.strokeRect(x - (offsetX ? offsetX : 0), y - (offsetY ? offsetY : 0), width, height); + } + + public static clone(other?: Block): Block { + if (other) { + let b = Block.create( + pond.Block.fromObject({ + settings: cloneDeep(other.settings), + img: new Image() + }) + ); + if (b.img) + switch (b.settings.type) { + case pond.BlockType.BLOCK_TYPE_VENT: + b.img.src = StandardDuctIcon; + break; + case pond.BlockType.BLOCK_TYPE_VENT_CORNER: + b.img.src = ConnectionDuctIcon; + break; + default: + b.img = undefined; + } + return b; + } + return Block.create(); + } + + public clone() { + return Block.create( + pond.Block.fromObject({ + settings: cloneDeep(this.settings), + img: undefined + }) + ); + } + + public clickCheck(x: number, y: number, extend?: number) { + extend = extend ? extend : 0; + if ( + x > this.x() - extend && + x < this.x() + this.width() + extend * 2 && + y > this.y() - extend && + y < this.y() + this.height() + extend * 2 + ) { + this.selected = true; + this.mouseOffsetX = x - this.x(); + this.mouseOffsetY = y - this.y(); + return true; + } + this.selected = false; + return false; + } + + public x(): number { + return this.settings.x; + } + + public endX(): number { + return this.settings.x + this.settings.width; + } + + public y(): number { + return this.settings.y; + } + + public endY(): number { + return this.settings.y + this.settings.height; + } + + public width(): number { + return this.settings.width; + } + + public height(): number { + return this.settings.height; + } + + public getCtrl(): boolean { + return this.ctrl; + } + + public setCtrl(c: boolean) { + this.ctrl = c; + } + + public getShift(): boolean { + return this.shift; + } + + public setShift(s: boolean) { + this.shift = s; + } + + public setSelected(s: boolean) { + this.selected = false; + } + + public radians(ang?: number) { + let angle = ang ? ang : this.settings.angle; + return angle * (Math.PI / 180); + } + + public distance(x1: number, x2: number, y1: number, y2: number) { + let a = x1 - x2; + let b = y1 - y2; + //console.log(a) + //console.log(b) + //console.log(x1) + //console.log(x2) + return Math.sqrt(a * a + b * b); + } + + public getSnap(rect: Block, snapDistance?: number) { + snapDistance = snapDistance ? snapDistance : 6; + let rect1 = this; + let rect2 = rect; + + // Check top right corner of moving square with top left of square 2 + let x2 = rect2.x(); + let y2 = rect2.y(); + let x = rect1.x(); + let y = rect1.y(); + let d = this.distance(rect1.x() + rect1.width(), rect2.x(), rect1.y(), y2); + if (d < snapDistance) { + return { + x: x2 - rect1.width(), + y: y2 + }; + } + + // Top left corner of moving square vs top right corner + d = this.distance(x, x2 + rect2.width(), y, y2); + if (d < snapDistance) { + return { + x: x2 + rect2.width(), + y: y2 + }; + } + + // Bottom left corner of moving square vs top left corner + d = this.distance(x, x2, y, y2 - rect2.height()); + if (d < snapDistance) { + return { + x: x2, + y: y2 - rect2.height() + }; + } + + // Top left corner of moving square vs bottom left corner + d = this.distance(x, x2, y, y2 + rect2.height()); + if (d < snapDistance) { + return { + x: x2, + y: y2 + rect2.height() + }; + } + } + + public drag = (x: number, y: number) => { + this.settings.x = x - this.mouseOffsetX; + this.settings.y = y - this.mouseOffsetY; + }; + + /*public drag = (e: any, offsetX?: number, offsetY?: number) => { + let x = e.pageX - (e.currentTarget.offsetLeft + e.currentTarget.clientLeft) - mouseOffsetX; + x = x - getOffsetX(e.currentTarget.offsetParent) + cameraOffsetX; + selected[0].settings.x = x; + let y = e.pageY - (e.currentTarget.offsetTop + e.currentTarget.clientTop) - mouseOffsetY; + y = y - getOffsetY(e.currentTarget.offsetParent) + cameraOffsetY; + selected[0].settings.y = y; + }*/ +} diff --git a/src/ventilation/drawable/Circle.ts b/src/ventilation/drawable/Circle.ts new file mode 100644 index 0000000..099361b --- /dev/null +++ b/src/ventilation/drawable/Circle.ts @@ -0,0 +1,41 @@ +export class Circle { + public radius = 1; + public x = 0; + public y = 0; + public selected = true; + public img: HTMLImageElement | undefined = undefined; + + public static create(x: number, y: number, radius: number) { + let c = new Circle(); + c.x = x; + c.y = y; + c.radius = radius; + return c; + } + + public draw(context: CanvasRenderingContext2D, offsetX?: number, offsetY?: number) { + offsetX = offsetX ? offsetX : 0; + offsetY = offsetY ? offsetY : 0; + let oldStyle = context.fillStyle; + context.beginPath(); + context.arc(this.x + offsetX, this.y + offsetY, this.radius, 0, 2 * Math.PI); + if (this.selected) { + context.fillStyle = "grey"; + context.fill(); + } + context.stroke(); + context.fillStyle = oldStyle; + } + + public clickCheck(x: number, y: number, scale = 1) { + let dx = Math.abs(x - this.x); + let dy = Math.abs(y - this.y); + let distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= this.radius) { + this.selected = true; + return true; + } + this.selected = false; + return false; + } +} diff --git a/src/ventilation/drawable/Fan.ts b/src/ventilation/drawable/Fan.ts new file mode 100644 index 0000000..abad770 --- /dev/null +++ b/src/ventilation/drawable/Fan.ts @@ -0,0 +1,207 @@ +import { pond } from "protobuf-ts/pond"; +import { Placeable } from "./Placeable"; +import Fan1Icon from "assets/editor/fan1.png"; +import Fan2Icon from "assets/editor/fan2.png"; +import Fan3Icon from "assets/editor/fan3.png"; +import { or } from "utils"; +import { SnapPoint } from "./geometry"; + +export class Fan implements Placeable { + public angle: number = 0; + public ctrl: boolean = false; + public shift: boolean = false; + public direction = 0; + //public snapPoints = [] + private mouseOffsetX = 0; + private mouseOffsetY = 0; + type: pond.PlaceableType = pond.PlaceableType.PLACEABLE_TYPE_FAN; + public subtype = 0; + + magnitude(): number { + return 0; + } + + radius(): number { + return this.fan_radius; + } + + highlight( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale = 1 + ): void { + offsetX = offsetX ? offsetX : 0; + offsetY = offsetY ? offsetY : 0; + + let x = (this.x - offsetX) * scale; + let y = (this.y - offsetY) * scale; + + context.beginPath(); + context.strokeStyle = "yellow"; + context.arc(x, y, this.radius() * scale, 0, 2 * Math.PI); + context.stroke(); + + context.strokeStyle = "white"; + } + + public drag = (x: number, y: number) => { + this.x = x - this.mouseOffsetX; + this.y = y - this.mouseOffsetY; + }; + + public getEndXAndY = (x = this.x, y = this.y, angle = this.angle, length = this.magnitude()) => { + return { x: x, y: y }; + }; + + getSnapPoints() { + let snaps: SnapPoint[] = []; + snaps.push({ + x: this.x, + y: this.y, + angle: this.angle, + startPoint: false, + occupant: undefined + } as SnapPoint); + return snaps; + } + + snap(p: Placeable, d: number): void { + d = d ? d : 12; + p.getSnapPoints().forEach(point => { + if (this.distance(this.x, point.x, this.y, point.y) < d) { + this.x = point.x; + this.y = point.y; + //this.angle = point.angle; + } + }); + } + + private fan_radius = 1; + public x = 0; + public y = 0; + public selected = true; + public img: HTMLImageElement | undefined = undefined; + + public static create(x: number, y: number, radius: number, subtype: number): Fan { + let c = new Fan(); + let i = new Image(); + i.src = Fan1Icon; + switch (subtype) { + case pond.FanType.FAN_TYPE_CENTRIFUGAL_INLINE: + i.src = Fan1Icon; + break; + case pond.FanType.FAN_TYPE_CENTRIFUGAL_HIGH_SPEED: + i.src = Fan2Icon; + break; + case pond.FanType.FAN_TYPE_CENTRIFUGAL_LOW_SPEED: + i.src = Fan3Icon; + break; + } + c.img = i; + c.x = x; + c.y = y; + c.fan_radius = radius; + c.subtype = subtype; + return c; + } + + public static fromPond(placeable: pond.Placeable) { + let f = new Fan(); + let i = new Image(); + i.src = Fan1Icon; + if (placeable.subtype) { + switch (placeable.subtype) { + case pond.FanType.FAN_TYPE_CENTRIFUGAL_INLINE: + i.src = Fan1Icon; + break; + case pond.FanType.FAN_TYPE_CENTRIFUGAL_HIGH_SPEED: + i.src = Fan2Icon; + break; + case pond.FanType.FAN_TYPE_CENTRIFUGAL_LOW_SPEED: + i.src = Fan3Icon; + break; + } + } + f.img = i; + f.x = placeable.x; + f.y = placeable.y; + f.fan_radius = or(placeable.radius, placeable.width / 2); + f.subtype = placeable.subtype; + return f; + } + + public draw(context: CanvasRenderingContext2D, offsetX?: number, offsetY?: number, scale = 1) { + offsetX = offsetX ? offsetX : 0; + offsetY = offsetY ? offsetY : 0; + let oldStyle = context.fillStyle; + if (this.img === undefined) { + context.beginPath(); + context.arc( + (this.x + offsetX) * scale, + (this.y + offsetY) * scale, + this.radius(), + 0, + 2 * Math.PI + ); + if (this.selected) { + context.fillStyle = "grey"; + context.fill(); + } + context.stroke(); + context.fillStyle = oldStyle; + } else { + context.fillStyle = "rgba(255, 255, 255, 255)"; + context.drawImage( + this.img, + (this.x - (offsetX ? offsetX : 0) - this.radius()) * scale, + (this.y - (offsetY ? offsetY : 0) - this.radius()) * scale, + this.radius() * 2 * scale, + this.radius() * 2 * scale + ); + } + } + + public distance(x1: number, x2: number, y1: number, y2: number) { + let a = x1 - x2; + let b = y1 - y2; + return Math.sqrt(a * a + b * b); + } + + public clickCheck(x: number, y: number) { + let dx = Math.abs(x - this.x); + let dy = Math.abs(y - this.y); + let distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= this.radius()) { + this.selected = true; + this.mouseOffsetX = x - this.x; + this.mouseOffsetY = y - this.y; + return true; + } + this.selected = false; + this.mouseOffsetX = 0; + this.mouseOffsetY = 0; + return false; + } + + public clone() { + let f = new Fan(); + f.img = this.img; + f.radius = this.radius; + f.selected = false; + f.x = this.x; + f.y = this.y; + return f; + } + + public getEndAngle(): number { + return this.angle; + } + + setDirection(direction: number): void {} + + setAngle(angle: number, oldAngle?: number) { + this.angle = angle; + //if (oldAngle) this.oldAngle = oldAngle + } +} diff --git a/src/ventilation/drawable/Placeable.ts b/src/ventilation/drawable/Placeable.ts new file mode 100644 index 0000000..7a224f4 --- /dev/null +++ b/src/ventilation/drawable/Placeable.ts @@ -0,0 +1,75 @@ +import { pond } from "protobuf-ts/pond"; +import { Fan } from "./Fan"; +import { VentCorner } from "./VentCorner"; +import { VentShaft } from "./VentShaft"; +import { VentTBreak } from "./VentTBreak"; +import { SnapPoint } from "./geometry"; + +export enum Direction { + CLOCKWISE, + COUNTERCLOCKWISE +} + +export interface Placeable { + x: number; + y: number; + angle: number; + ctrl: boolean; + shift: boolean; + selected: boolean; + type: pond.PlaceableType; + subtype: number; + direction: number; + magnitude(): number; + radius(): number; + clone(): any; + clickCheck(x: number, y: number, scale?: number): boolean; + highlight( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale?: number + ): void; + drag(x: number, y: number, scale?: number): void; + getEndXAndY(x?: number, y?: number, angle?: number, length?: number): { x: number; y: number }; + getEndAngle(): number; + getSnapPoints(): SnapPoint[]; + snap(p: Placeable, d?: number): void; + draw(context: CanvasRenderingContext2D, offsetX?: number, offsetY?: number, scale?: number): void; + setDirection(direction: number | Direction): void; + setAngle(angle: number, oldAngle?: number): void; +} + +export function loadPlaceable(shaft: pond.Placeable) { + if (shaft.direction === undefined) shaft.direction = 0; + if (shaft.angle === undefined) shaft.angle = 0; + + // The pond may store the type as a string or number + let type = pond.PlaceableType.PLACEABLE_TYPE_DEFAULT.valueOf(); + if (shaft.type !== undefined) { + if (typeof shaft.type === "string") { + type = (pond.PlaceableType[shaft.type] as unknown) as number; + } else { + type = +pond.PlaceableType[shaft.type]; + } + } + + switch (type) { + case pond.PlaceableType.PLACEABLE_TYPE_SHAFT: + if (shaft.subtype) { + switch (shaft.subtype) { + case pond.ShaftType.SHAFT_TYPE_DEFAULT: + return VentShaft.fromPond(shaft); + case pond.ShaftType.SHAFT_TYPE_CORNER: + return VentCorner.fromPond(shaft); + case pond.ShaftType.SHAFT_TYPE_T_BREAK: + return VentTBreak.fromPond(shaft); + } + } + return VentShaft.fromPond(shaft); + case pond.PlaceableType.PLACEABLE_TYPE_FAN: + return Fan.fromPond(shaft); + default: + return VentShaft.fromPond(shaft); + } +} diff --git a/src/ventilation/drawable/Sensor.ts b/src/ventilation/drawable/Sensor.ts new file mode 100644 index 0000000..8d78fbd --- /dev/null +++ b/src/ventilation/drawable/Sensor.ts @@ -0,0 +1,300 @@ +import { pond } from "protobuf-ts/pond"; +import { Placeable } from "./Placeable"; +import { Component, Device, User } from "models"; +import { UnitMeasurement } from "models/UnitMeasurement"; +import { SnapPoint } from "./geometry"; + +export class Sensor implements Placeable { + public debug = false; + public x = 0; + public y = 0; + public angle = 0; + public ctrl = false; + public shift = false; + public selected = false; + public type = pond.PlaceableType.PLACEABLE_TYPE_SENSOR; + public direction = 0; + public subtype = pond.ShaftType.SHAFT_TYPE_DEFAULT; + public index = 0; + + private sensor_radius = 8; + + //private circle = Circle.create(0, 0, 0); + private mouseOffsetX = 0; + private mouseOffsetY = 0; + private measurements: UnitMeasurement[] = []; + + private component: Component = new Component(); + private device: Device = new Device(); + private preferences: pond.MineSensorPreferences = new pond.MineSensorPreferences(); + + public getComponent() { + return this.component; + } + + public name() { + return this.preferences.nickname; + } + + public getDevice() { + return this.device; + } + + public getMeasurements() { + return this.measurements; + } + + public getPreferences() { + return this.preferences; + } + + public magnitude(): number { + return this.sensor_radius; + } + + public radius(): number { + return this.sensor_radius; + } + + public static fromPond(shaft: pond.Placeable) { + let v = new Sensor(); + v.x = shaft.x; + v.y = shaft.y; + v.angle = shaft.angle; + + document.body.addEventListener("keydown", function(event) { + if (event.key === "Control") { + v.ctrl = true; + } else if (event.key === "Shift") { + v.shift = true; + } + }); + + document.body.addEventListener("keyup", function(event) { + if (event.key === "Control") { + v.ctrl = false; + } else if (event.key === "Shift") { + v.shift = false; + } + }); + + return v; + } + + public static create( + component: Component, + device: Device, + preferences: pond.MineSensorPreferences, + measurements: UnitMeasurement[], + index: number, + user?: User + ): Sensor { + let v = new Sensor(); + + v.x = preferences.x; + v.y = preferences.y; + v.index = index; + + v.device = device; + v.component = component; + v.preferences = preferences; + v.measurements = measurements; + + /*component.lastMeasurement.forEach(m => { + let um = UnitMeasurement.create(m, user); + v.measurements.push(um); + });*/ + + document.body.addEventListener("keydown", function(event) { + if (event.key === "Control") { + v.ctrl = true; + } else if (event.key === "Shift") { + v.shift = true; + } + }); + + document.body.addEventListener("keyup", function(event) { + if (event.key === "Control") { + v.ctrl = false; + } else if (event.key === "Shift") { + v.shift = false; + } + }); + + return v; + } + + public draw(context: CanvasRenderingContext2D, offsetX?: number, offsetY?: number, scale = 1) { + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + let r = this.radius() * scale; + + context.fillStyle = "rgba(0, 200, 0, 255)"; + context.beginPath(); + context.arc(x * scale, y * scale, r + 0.1, 2 * Math.PI, 0, false); + context.closePath(); + context.fill(); + } + + public getEndXAndY = () => { + return { x: this.endX(), y: this.endY() }; + }; + + public clickCheck = (x: number, y: number, scale = 1) => { + let dx = Math.abs(x - this.x); + let dy = Math.abs(y - this.y); + let distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= this.radius()) { + this.selected = true; + return true; + } + this.selected = false; + return false; + }; + + public highlight( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale = 1 + ) { + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + + //scale it + x = x * scale; + y = y * scale; + let radius = this.radius() * scale; + + // Highlight circle + context.beginPath(); + context.arc(x + 0.5, y + 0.5, radius + 2, 0, 2 * Math.PI, false); + context.closePath(); + context.strokeStyle = "rgb(255, 255, 255)"; + context.stroke(); + + // Line going up + context.beginPath(); + context.moveTo(x, y - (radius + 2 * scale)); + context.lineTo(x, y - (radius + 12)); + context.closePath(); + context.stroke(); + + // Frame + let padding = 8; + let width = 0; + this.measurements.forEach((m, i) => { + if (m.values[0].values[this.index]) { + context.font = "18px Times New Roman"; + width = width + context.measureText(m.values[0].values[this.index].toFixed(1)).width; + context.font = "14px Times New Roman"; + width = width + context.measureText(m.unit).width; + } + if (i < this.measurements.length - 1) width = width + context.measureText(", ").width; + }); + context.font = "18px Times New Roman"; + width = Math.max(width, context.measureText(this.preferences.nickname).width) + 0.1; + context.moveTo(x, y - (radius + 12)); + context.clearRect(x, y - (radius + 42 + padding * 2), width + padding * 2, 46); + context.lineTo(x + width + 1 + padding * 2, y - (radius + 12)); + context.lineTo(x + width + 1 + padding * 2, y - (radius + 42 + padding * 2)); + context.lineTo(x, y - (radius + 42 + padding * 2)); + context.lineTo(x, y - (radius + 12)); + context.stroke(); + + // Reading + let valueString = ""; + let newx = x + padding; + this.measurements.forEach((m, i) => { + newx = context.measureText(valueString).width + newx; + valueString = ""; + if (!m.values[0].values[this.index]) { + valueString = valueString + "NaN"; + } else { + valueString = valueString + m.values[0].values[this.index].toFixed(1); + } + valueString = valueString + m.unit; + if (i < this.measurements.length - 1) valueString = valueString + ", "; + context.fillStyle = m.colour; + context.font = "14px Times New Roman"; + context.fillText(valueString, newx, y - (radius + 12) - padding); + }); + + // Label + context.fillStyle = "white"; + context.font = "18px Times New Roman, sans-serif"; + context.fillText(this.preferences.nickname, x + padding, y - (radius + 30 + padding)); + } + + public clone() { + let v = new Sensor(); + v.x = this.x; + v.y = this.y; + v.angle = this.angle; + //v.circle = this.circle; + v.ctrl = this.ctrl; + v.shift = this.shift; + v.radius = this.radius; + return v; + } + + public distance(x1: number, x2: number, y1: number, y2: number) { + let a = x1 - x2; + let b = y1 - y2; + return Math.sqrt(a * a + b * b); + } + + getSnapPoints() { + let snaps: SnapPoint[] = []; + snaps.push({ + x: this.x, + y: this.y, + angle: this.angle, + startPoint: false, + occupant: undefined + } as SnapPoint); + snaps.push({ + x: this.x, + y: this.y, + angle: this.angle + 180, + startPoint: true, + occupant: undefined + } as SnapPoint); + if (snaps[1].angle > 360) snaps[1].angle = snaps[1].angle - 360; + return snaps; + } + + public snap = (p: Placeable, d: number): void => { + d = d ? d : 12; + p.getSnapPoints().forEach(point => { + if (this.distance(this.x, point.x, this.y, point.y) < d) { + this.x = point.x; + this.y = point.y; + this.angle = point.angle; + } + }); + }; + + public drag = (x: number, y: number, scale = 1) => { + this.x = x - this.mouseOffsetX; + this.y = y - this.mouseOffsetY; + }; + + public endX(): number { + return this.x; + } + + public endY(): number { + return this.y; + } + + public getEndAngle(): number { + return this.angle; + } + + setDirection(direction: number): void {} + + setAngle(angle: number) { + this.angle = angle; + } +} diff --git a/src/ventilation/drawable/VentBezier.ts b/src/ventilation/drawable/VentBezier.ts new file mode 100644 index 0000000..b60ab37 --- /dev/null +++ b/src/ventilation/drawable/VentBezier.ts @@ -0,0 +1,328 @@ +import { pond } from "protobuf-ts/pond"; +import { cloneDeep } from "lodash"; +import { or } from "utils"; +import { Block } from "./Block"; +import { Circle } from "./Circle"; +import {} from "../Editor"; + +const remap = (value: number, sourceMin: number, sourceMax: number, destMin = 0.5, destMax = 1) => { + let sourceRange = sourceMax - sourceMin; + let destRange = destMax - destMin; + let newValue = ((value - sourceMin) * destRange) / sourceRange + destMin; + //console.log("Value: " + value + "\nmin: " + sourceMin + "\nmax: " + sourceMax) + return newValue; +}; +export class VentBezier extends Block { + public circle = Circle.create(0, 0, 6); + public settings: pond.BlockSettings = pond.BlockSettings.create(); + public selected = false; + public img = undefined; + public mouseOffsetX = 0; + public mouseOffsetY = 0; + public startSnap: Block | undefined = undefined; + public endSnap: Block | undefined = undefined; + + public ctrl = false; + public shift = false; + + public static create(pb?: pond.Block): VentBezier { + let my = new VentBezier(); + if (pb) { + my.settings = pond.BlockSettings.fromObject(cloneDeep(or(pb.settings, {}))); + // For some reason, negative x and y values don't clone correctly + if (pb.settings) { + my.settings.x = pb.settings.x; + my.settings.y = pb.settings.y; + my.settings.width = pb.settings.width; + my.settings.height = pb.settings.height; + //my.circle.radius = 6 + } + } + my.ctrl = false; + my.shift = false; + return my; + } + + public magnitude(): number { + return 0; + } + + public clone() { + return new Block(); + } + + private drawBezier( + context: CanvasRenderingContext2D, + x: number, + y: number, + endx: number, + endy: number, + offsetx: number, + offsety: number + ) { + if (this.startSnap === undefined) return; + + if (this.startSnap?.settings.angle === 0) { + this.startSnap.settings.angle = 360; + } + + let h = this.distance(x, endx, y, endy); + let theta1 = Math.asin((endy - y) / h); + + if (endx - x < 0 && this.settings.angle < 180 && this.settings.angle >= 0) { + if (theta1 < 0) { + let diff = 90 - Math.abs(theta1 / (Math.PI / 180)); + theta1 = (theta1 / (Math.PI / 180) - diff * 2) * (Math.PI / 180); + } else { + let diff = 90 - Math.abs(theta1 / (Math.PI / 180)); + theta1 = (theta1 / (Math.PI / 180) + diff * 2) * (Math.PI / 180); + } + } + + let angleRatio = remap( + Math.abs(theta1), + this.startSnap?.radians(), + this.startSnap?.radians() - 45 * (Math.PI / 180) + ); + if (this.startSnap.settings.angle > 180 && this.startSnap.settings.angle <= 270) { + x = remap( + Math.abs(theta1), + this.startSnap.radians() - 135 * (Math.PI / 180), + this.startSnap.radians() - 225 * (Math.PI / 180) + ); + } + + angleRatio = Math.abs(angleRatio); + if (angleRatio < 0.5) { + angleRatio = 0.5 + (0.5 - angleRatio); + } + if (angleRatio > 1) { + angleRatio = 1; + } + + let theta = theta1 + this.startSnap.radians(); + let a = Math.cos(theta); + a = a * h; + a = a * angleRatio; + let dx = Math.cos(this.radians(this.startSnap.settings.angle)) * a; + let dy = Math.sin(this.radians(this.startSnap.settings.angle)) * a; + + if (this.endSnap) { + } + + // Bezier curve offset points + /*context.beginPath(); + context.moveTo(x - offsetx, y - offsety); + context.lineTo(x + dx - offsetx, y - dy - offsety); + context.stroke();*/ + + let dx1 = x + dx * 0.8; + let dy1 = y - dy * 0.8; + + /*context.beginPath(); + context.arc(dx1 - offsetx, dy1 - offsety, 6, 0, 2 * Math.PI); + context.stroke(); + + context.beginPath(); + context.moveTo(x + dx - offsetx, y - dy - offsety); + context.lineTo(endx - offsetx, endy - offsety); + context.stroke();*/ + + let dx2 = endx - (x + dx); + dx2 = x + dx + dx2 * 0.2; + let dy2 = endy - (y - dy); + dy2 = y - dy + dy2 * 0.2; + + /*context.beginPath(); + context.arc(0 - offsetx, 0 - offsety, 0, 0, 2 * Math.PI); + context.stroke(); + + context.beginPath(); + context.arc(dx2 - offsetx, dy2 - offsety, 6, 0, 2 * Math.PI); + context.stroke(); + + context.beginPath(); + context.arc(x + dx - offsetx, y - dy - offsety, 6, 0, 2 * Math.PI); + context.stroke();*/ + + context.moveTo(x - offsetx, y - offsety); + context.strokeStyle = "rgb(255, 255, 255)"; + context.bezierCurveTo( + dx1 - offsetx, + dy1 - offsety, + dx2 - offsetx, + dy2 - offsety, + endx - offsetx, + endy - offsety + ); + context.stroke(); + } + + public draw(context: CanvasRenderingContext2D, offsetX?: number, offsetY?: number) { + let ox = offsetX ? offsetX : 0; + let oy = offsetY ? offsetY : 0; + + if (this.startSnap) { + if (this.startSnap.settings.angle === 0) { + this.startSnap.settings.angle = 360; + } + + if (this.circle.selected) { + this.drawBezier(context, this.x(), this.y(), this.width(), this.height(), ox, oy); + } else if (this.startSnap) { + let radius = this.startSnap.height() / 2; + let dy1 = Math.cos(this.startSnap.radians()) * radius; + let dx1 = Math.sin(this.startSnap.radians()) * radius; + if (this.endSnap) { + let dy2 = Math.cos(this.endSnap.radians()) * radius; + let dx2 = Math.sin(this.endSnap.radians()) * radius; + this.drawBezier( + context, + this.x() - dx1, + this.y() - dy1, + this.width() - dx2, + this.height() - dy2, + ox, + oy + ); + this.drawBezier( + context, + this.x() + dx1, + this.y() + dy1, + this.width() + dx2, + this.height() + dy2, + ox, + oy + ); + } else { + this.drawBezier( + context, + this.x() - dx1, + this.y() - dy1, + this.width(), + this.height(), + ox, + oy + ); + } + /*console.log(dx1 + ", " + dy1); + context.strokeStyle = "rgb(255, 255, 255)"; + context.beginPath(); + context.strokeStyle = "rgb(255, 255, 255)"; + context.arc(this.x() - dx1 - ox, this.y() - dy1 - oy, 6, 0, 2 * Math.PI); + context.stroke();*/ + } + } + + /*context.strokeStyle = "rgb(255, 255, 255)"; + context.beginPath(); + context.strokeStyle = "rgb(255, 255, 255)"; + context.arc(this.x() - ox, this.y() - oy, 6, 0, 2 * Math.PI); + context.stroke();*/ + + context.beginPath(); + context.arc(this.width() - ox, this.height() - oy, 6, 0, 2 * Math.PI); + context.stroke(); + } + + public highlight(context: CanvasRenderingContext2D, ox: number, oy: number) { + context.fillStyle = "rgb(255, 255, 255)"; + context.globalAlpha = 0.3; + context.beginPath(); + context.arc(this.width() - ox, this.height() - oy, 6, 0, 2 * Math.PI); + context.fill(); + context.globalAlpha = 1; + } + + public clickCheck(x: number, y: number, extend?: number) { + if (this.distance(x, this.endX(), y, this.endY()) <= this.circle.radius) { + this.circle.selected = true; + return true; + } + return false; + } + + public getSnap(rect: Block, snapDistance: number = 6) { + if (rect.x() === this.startSnap?.x() && rect.y() === this.startSnap?.y()) return undefined; + if ( + Math.abs(this.endX() - rect.x()) < snapDistance && + Math.abs(this.endY() - rect.y()) < snapDistance + ) { + this.settings.width = rect.x(); + this.settings.height = rect.y(); + this.endSnap = rect; + } + return undefined; + } + + public drag = (x: number, y: number) => { + if (this.circle.selected) { + this.settings.width = x; + this.settings.height = y; + let dx = this.settings.width - this.settings.x; + let dy = this.settings.height - this.settings.y; + if (Math.abs(dx) > Math.abs(dy)) { + dy = dx; + } else { + dx = dy; + } + if (this.endSnap) { + this.endSnap.settings.x = x; + this.endSnap.settings.y = y; + } + this.settings.angle = Math.abs((Math.atan(dy / dx) * 180) / Math.PI); + } /*else { + let dx = this.settings.width - this.settings.x; + let dy = this.settings.height - this.settings.y; + this.settings.x = x; + this.settings.y = y; + this.settings.width = dx; + this.settings.height = dy; + }*/ + }; + + public x(): number { + return this.settings.x; + } + + public y(): number { + return this.settings.y; + } + + public endX(): number { + return this.settings.width; + } + + public endY(): number { + return this.settings.height; + } + + public width(): number { + return this.settings.width; + } + + public height(): number { + return this.settings.height; + } + + public getCtrl(): boolean { + return this.ctrl; + } + + public getShift(): boolean { + return this.shift; + } + + public setCtrl(ctrl: boolean) { + this.ctrl = ctrl; + } + + public setShift(shift: boolean) { + this.shift = shift; + } + + public setSelected(s: boolean) { + this.selected = s; + this.circle.selected = s; + } +} diff --git a/src/ventilation/drawable/VentCorner.ts b/src/ventilation/drawable/VentCorner.ts new file mode 100644 index 0000000..a032429 --- /dev/null +++ b/src/ventilation/drawable/VentCorner.ts @@ -0,0 +1,351 @@ +import { pond } from "protobuf-ts/pond"; +import { Direction, Placeable } from "./Placeable"; +import { SnapPoint } from "./geometry"; + +export class VentCorner implements Placeable { + x: number = 0; + y: number = 0; + direction = Direction.CLOCKWISE; + angle: number = 0; + ctrl: boolean = false; + shift: boolean = false; + selected: boolean = false; + type: pond.PlaceableType = pond.PlaceableType.PLACEABLE_TYPE_SHAFT; + subtype = pond.ShaftType.SHAFT_TYPE_CORNER; + + // Unique to VentCorner + private corner_radius: number = 128; + private width: number = 64; + private mouseOffsetX = 0; + private mouseOffsetY = 0; + private oldAngle = 0; + private snapped = false; + + public static create = (x?: number, y?: number, radius?: number, startAngle?: number) => { + let v = new VentCorner(); + v.x = x ? x : 0; + v.y = y ? y : 0; + v.angle = startAngle ? startAngle : 0; + v.oldAngle = startAngle ? startAngle : 0; + v.corner_radius = radius ? radius : 128; + return v; + }; + + public static fromPond(shaft: pond.Placeable) { + let v = new VentCorner(); + v.x = shaft.x; + v.y = shaft.y; + v.angle = shaft.angle; + v.corner_radius = shaft.magnitude; + v.direction = shaft.direction; + + document.body.addEventListener("keydown", function(event) { + if (event.key === "Control") { + v.ctrl = true; + } else if (event.key === "Shift") { + v.shift = true; + } + }); + + document.body.addEventListener("keyup", function(event) { + if (event.key === "Control") { + v.ctrl = false; + } else if (event.key === "Shift") { + v.shift = false; + } + }); + + return v; + } + + public magnitude = (): number => { + return this.radius(); + }; + + public radius = (): number => { + return this.corner_radius; + }; + + public clone = () => { + let v = new VentCorner(); + v.x = this.x; + v.y = this.y; + v.angle = this.angle; + v.radius = this.radius; + return v; + }; + + private distance(x: number, y: number, x2?: number, y2?: number) { + let a = this.x - x; + let b = this.y - y; + if (x2) { + a = x2 - x; + } + if (y2) { + b = y2 - y; + } + return Math.sqrt(a * a + b * b); + } + + public clickCheck(x: number, y: number, scale = 1): boolean { + // Check angle of click relative to the radius + let radians = this.angle * (Math.PI / 180); + + //scale it + let radius = this.radius(); + let width = this.width; + + let centerx = 0; + let centery = 0; + if (this.direction === Direction.CLOCKWISE) { + centerx = this.x + Math.cos(radians - 0.5 * Math.PI) * radius; + centery = this.y - Math.sin(radians - 0.5 * Math.PI) * radius; + } else { + centerx = this.x + Math.cos(radians - 1.5 * Math.PI) * radius; + centery = this.y - Math.sin(radians - 1.5 * Math.PI) * radius; + } + + let { x: endx, y: endy } = this.getEndXAndY(scale); + + // Check that distance from radial center is greater than + // inner radius and less than outer radius + let d = this.distance(centerx, centery, x, y); + if (d > radius - width / 2 && d < radius + width / 2) { + let dx = x - centerx; + let dy = y - centery; + let radians = Math.atan2(dy, dx); + if (radians < 0) { + radians = radians + 2 * Math.PI; + } + let ratio = (2 * (radians / (Math.PI / 180))) / 360; + dx = this.x - centerx; + dy = this.y - centery; + radians = Math.atan2(dy, dx); + if (radians < 0) { + radians = radians + 2 * Math.PI; + } + let ratioLow = (2 * (radians / (Math.PI / 180))) / 360; + dx = endx - centerx; + dy = endy - centery; + radians = Math.atan2(dy, dx); + if (radians < 0) { + radians = radians + 2 * Math.PI; + } + let ratioHigh = (2 * (radians / (Math.PI / 180))) / 360; + if (this.direction === Direction.COUNTERCLOCKWISE) { + if (ratioHigh > ratioLow) { + ratioLow = ratioLow + 2; + if (ratio < ratioHigh) ratio = ratio + 2; + } + if (ratio > ratioHigh && ratio < ratioLow) { + this.mouseOffsetX = x - this.x; + this.mouseOffsetY = y - this.y; + this.selected = true; + return true; + } else { + return false; + } + } + + if (ratioHigh < ratioLow) { + ratioHigh = ratioHigh + 2; + if (ratio < ratioLow) ratio = ratio + 2; + } + + // If the angle is within the start and end of the arc relativ eto the radial center + if (ratio > ratioLow && ratio < ratioHigh) { + this.mouseOffsetX = x - this.x; + this.mouseOffsetY = y - this.y; + this.selected = true; + return true; + } + } + this.selected = false; + if (this.snapped) { + this.oldAngle = this.angle; + } + return false; + } + + public draw( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale = 1 + ): void { + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + + //scale it + x = x * scale; + y = y * scale; + let radius = this.radius() * scale; + let width = this.width * scale; + + let radians = this.angle * (Math.PI / 180); + let ratio = (2 * this.angle) / 360; + ratio = 2 - ratio; + + let angleOffset = 0.5; + let c = false; + if (this.direction === Direction.COUNTERCLOCKWISE) { + x = x - Math.cos(radians - 0.5 * Math.PI) * radius; + y = y + Math.sin(radians - 0.5 * Math.PI) * radius; + angleOffset = 1.5; + c = true; + } else { + x = x + Math.cos(radians - 0.5 * Math.PI) * radius; + y = y - Math.sin(radians - 0.5 * Math.PI) * radius; + } + + context.strokeStyle = "white"; + context.beginPath(); + context.arc(x, y, radius - width / 2, (ratio - angleOffset) * Math.PI, ratio * Math.PI, c); + context.stroke(); + + context.beginPath(); + context.arc(x, y, radius + width / 2, (ratio - angleOffset) * Math.PI, ratio * Math.PI, c); + context.stroke(); + } + + highlight( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale = 1 + ): void { + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + let radians = this.angle * (Math.PI / 180); + let ratio = (2 * this.angle) / 360; + ratio = 2 - ratio; + + //scale it + x = x * scale; + y = y * scale; + let radius = this.radius() * scale; + let width = this.width * scale; + + context.strokeStyle = "yellow"; + context.beginPath(); + context.arc(x, y, 6, 0, 2 * Math.PI); + context.stroke(); + + let angleOffset = 0.5; + let c = false; + if (this.direction === Direction.COUNTERCLOCKWISE) { + x = x - Math.cos(radians - 0.5 * Math.PI) * radius; + y = y + Math.sin(radians - 0.5 * Math.PI) * radius; + angleOffset = 1.5; + c = true; + } else { + x = x + Math.cos(radians - 0.5 * Math.PI) * radius; + y = y - Math.sin(radians - 0.5 * Math.PI) * radius; + } + + //x = x + Math.cos(radians - 0.5 * Math.PI) * this.radius; + //y = y - Math.sin(radians - 0.5 * Math.PI) * this.radius; + + context.beginPath(); + context.arc( + x, + y, + radius - (width + 2) / 2, + (ratio - angleOffset) * Math.PI, + ratio * Math.PI, + c + ); + context.stroke(); + + context.beginPath(); + context.arc( + x, + y, + radius + (width + 2) / 2, + (ratio - angleOffset) * Math.PI, + ratio * Math.PI, + c + ); + context.stroke(); + } + + drag(x: number, y: number, scale = 1): void { + this.x = x - this.mouseOffsetX; + this.y = y - this.mouseOffsetY; + } + + getEndXAndY(scale = 1): { x: number; y: number } { + let ratio = (2 * this.angle) / 360; + + //scale it + let x = this.x; + let y = this.y; + let radius = this.radius(); + //let width = this.width*scale + + if (this.direction === Direction.COUNTERCLOCKWISE) { + x = x - Math.cos((ratio - 0.5) * Math.PI) * radius; + y = y + Math.sin((ratio - 0.5) * Math.PI) * radius; + x = x + Math.sin((ratio - 1.5) * Math.PI) * radius; + y = y + Math.cos((ratio - 1.5) * Math.PI) * radius; + return { x: x, y: y }; + } + x = x + Math.cos((ratio - 0.5) * Math.PI) * radius; + y = y - Math.sin((ratio - 0.5) * Math.PI) * radius; + x = x - Math.sin((ratio - 0.5) * Math.PI) * radius; + y = y - Math.cos((ratio - 0.5) * Math.PI) * radius; + return { x: x, y: y }; + } + + getSnapPoints() { + let snaps: SnapPoint[] = []; + + let { x: endx, y: endy } = this.getEndXAndY(); + snaps.push({ + x: endx, + y: endy, + angle: this.getEndAngle(), + occupant: undefined, + startPoint: false + } as SnapPoint); + + snaps.push({ + x: this.x, + y: this.y, + angle: this.angle + 180, + occupant: undefined, + startPoint: true + } as SnapPoint); + if (snaps[1].angle > 360) snaps[0].angle = snaps[0].angle - 360; + + return snaps; + } + + snap(p: Placeable, d?: number): void { + if (!d) d = 12; + p.getSnapPoints().forEach(point => { + if (!point.startPoint && this.distance(this.x, this.y, point.x, point.y) < d!) { + this.x = point.x; + this.y = point.y; + this.angle = point.angle; + } + }); + } + + getEndAngle(): number { + if (this.direction === Direction.COUNTERCLOCKWISE) { + return this.angle + 90; + } + let ang = this.angle - 90; + return ang; + } + + setDirection(direction: number | Direction): void { + this.direction = direction; + } + + setAngle(angle: number, oldAngle?: number) { + this.angle = angle; + if (oldAngle) this.oldAngle = oldAngle; + } +} diff --git a/src/ventilation/drawable/VentShaft.ts b/src/ventilation/drawable/VentShaft.ts new file mode 100644 index 0000000..8af732e --- /dev/null +++ b/src/ventilation/drawable/VentShaft.ts @@ -0,0 +1,438 @@ +import { pond } from "protobuf-ts/pond"; +import { Placeable } from "./Placeable"; +import { Circle } from "./Circle"; +import { SnapPoint } from "./geometry"; + +export class VentShaft implements Placeable { + public debug = false; + public x = 0; + public y = 0; + public angle = 0; + public ctrl = false; + public shift = false; + public selected = false; + public type = pond.PlaceableType.PLACEABLE_TYPE_SHAFT; + public direction = 0; + public subtype = pond.ShaftType.SHAFT_TYPE_DEFAULT; + + private length = 128; + private diameter = 64; + + private circle = Circle.create(0, 0, 0); + private mouseOffsetX = 0; + private mouseOffsetY = 0; + private oldAngle = 0; + private snapped = false; + + public magnitude(): number { + return this.length; + } + + public radius(): number { + return this.diameter / 2; + } + + public static fromPond(shaft: pond.Placeable) { + let v = new VentShaft(); + v.x = shaft.x; + v.y = shaft.y; + v.angle = shaft.angle; + v.length = shaft.magnitude; + + document.body.addEventListener("keydown", function(event) { + if (event.key === "Control") { + v.ctrl = true; + } else if (event.key === "Shift") { + v.shift = true; + } + }); + + document.body.addEventListener("keyup", function(event) { + if (event.key === "Control") { + v.ctrl = false; + } else if (event.key === "Shift") { + v.shift = false; + } + }); + + return v; + } + + public static create(pb?: pond.Block, img?: HTMLImageElement): VentShaft { + let v = new VentShaft(); + //v.settings = pond.BlockSettings.fromObject(cloneDeep(or(pb?.settings, {}))); + + // For some reason, negative x and y values don't clone correctly + // Same goes for decimal values in angle + if (pb?.settings) { + v.x = pb.settings.x; + v.y = pb.settings.y; + v.angle = pb.settings.angle; + } + + document.body.addEventListener("keydown", function(event) { + if (event.key === "Control") { + v.ctrl = true; + } else if (event.key === "Shift") { + v.shift = true; + } + }); + + document.body.addEventListener("keyup", function(event) { + if (event.key === "Control") { + v.ctrl = false; + } else if (event.key === "Shift") { + v.shift = false; + } + }); + + return v; + } + + public draw( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale?: number + ) { + if (!scale) scale = 1; + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + let width = this.length; + let height = this.diameter; + + //scale it + x = x * scale; + y = y * scale; + width = width * scale; + height = height * scale; + + let cos = Math.cos; + let sin = Math.sin; + + let ang = this.angle; + let rad = ang * (Math.PI / 180); + + y = y - cos(rad) * (height / 2); + x = x - sin(rad) * (height / 2); + + let x2 = x + cos(rad) * width; + let y2 = y - sin(rad) * width; + + if (this.debug) { + context.beginPath(); + context.arc(x, y, 6, 0, 2 * Math.PI); + context.stroke(); + + context.strokeStyle = "rgb(0, 0, 255)"; + context.beginPath(); + context.arc(x2, y2, 6, 0, 2 * Math.PI); + context.stroke(); + + context.strokeStyle = "rgb(0, 255, 0)"; + context.beginPath(); + context.arc( + x + cos((ang - 90) * (Math.PI / 180)) * height, + y - sin((ang - 90) * (Math.PI / 180)) * height, + 6, + 0, + 2 * Math.PI + ); + context.stroke(); + + context.strokeStyle = "rgb(255, 0, 0)"; + context.beginPath(); + context.arc( + x2 + cos((ang - 90) * (Math.PI / 180)) * height, + y2 - sin((ang - 90) * (Math.PI / 180)) * height, + 6, + 0, + 2 * Math.PI + ); + context.stroke(); + } + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x2, y2); + context.closePath(); + context.moveTo( + x + cos((ang - 90) * (Math.PI / 180)) * height, + y - sin((ang - 90) * (Math.PI / 180)) * height + ); + context.lineTo( + x2 + cos((ang - 90) * (Math.PI / 180)) * height, + y2 - sin((ang - 90) * (Math.PI / 180)) * height + ); + context.closePath(); + context.strokeStyle = "rgba(255, 255, 255, 1)"; + context.stroke(); + } + + /*public static clone(p: Placeable): VentShaft { + if (p) { + let b = VentShaft.create( + pond.Block.fromObject({ + settings: cloneDeep(other.settings), + img: new Image() + }) + ); + let v = new VentShaft() + v.x = p.x; + v.y = p.y; + v.angle = p.angle; + v.circle = Circle.create(0, 0, 0); + v.ctrl = p.ctrl; + v.shift = p.shift; + v.diameter = p.diameter; + v.length = p.length; + if (b.img) + switch (b.settings.type) { + case pond.BlockType.BLOCK_TYPE_VENT: + b.img.src = StandardDuctIcon; + break; + case pond.BlockType.BLOCK_TYPE_VENT_CORNER: + b.img.src = ConnectionDuctIcon; + break; + default: + b.img = undefined; + } + return b; + } + return VentShaft.create(); + }*/ + + public getEndXAndY = () => { + return { x: this.endX(), y: this.endY() }; + }; + + public clickCheck = (x: number, y: number, scale = 1) => { + let x1 = this.x; + let y1 = this.y; + let width = this.length; + let height = this.diameter; + + let cos = Math.cos; + let sin = Math.sin; + + let ang = this.angle; + let rad = ang * (Math.PI / 180); + + let cx = x1 + Math.cos(rad) * width; + let cy = y1 - Math.sin(rad) * width; + this.circle.x = cx; + this.circle.y = cy; + this.circle.radius = height / 4; + if (this.circle.clickCheck(x, y, scale)) return true; + + y1 = y1 - cos(rad) * (height / 2); + x1 = x1 - sin(rad) * (height / 2); + + let x2 = x1 + cos(rad) * width; + let y2 = y1 - sin(rad) * width; + + let x3 = x1 + cos((ang - 90) * (Math.PI / 180)) * height; + let y3 = y1 - sin((ang - 90) * (Math.PI / 180)) * height; + + let x4 = x2 + cos((ang - 90) * (Math.PI / 180)) * height; + let y4 = y2 - sin((ang - 90) * (Math.PI / 180)) * height; + + let D1 = (x2 - x1) * (y - y1) - (x - x1) * (y2 - y1); + let D2 = (x4 - x2) * (y - y2) - (x - x2) * (y4 - y2); + let D3 = (x3 - x4) * (y - y4) - (x - x4) * (y3 - y4); + let D4 = (x1 - x3) * (y - y3) - (x - x3) * (y1 - y3); + + if (D1 > 0 && D2 > 0 && D3 > 0 && D4 > 0) { + this.selected = true; + this.mouseOffsetX = x - this.x; + this.mouseOffsetY = y - this.y; + return true; + } + this.selected = false; + if (this.snapped) { + this.oldAngle = this.angle; + this.snapped = false; + } + return false; + }; + + public highlight( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale?: number + ) { + if (!scale) scale = 1; + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + let width = this.length; + let height = this.diameter; + + //scale it + x = x * scale; + y = y * scale; + width = width * scale; + height = height * scale; + + let ang = this.angle; + let rad = ang * (Math.PI / 180); + + // Draw selection circle + let cx = x + Math.cos(rad) * width; + let cy = y - Math.sin(rad) * width; + this.circle.x = cx; + this.circle.y = cy; + this.circle.radius = height / 4; + this.circle.draw(context); + + let x1 = x - Math.sin(rad) * ((height + 3) / 2); + let y1 = y - Math.cos(rad) * ((height + 3) / 2); + + context.strokeStyle = "rgba(247, 202, 24, 1)"; + + // Make the point of rotation the objects x and y before making its rotation + context.translate(x1, y1); + context.rotate(-rad); + context.translate(-x1, -y1); + + // Draw the rectangle and then rotate back to original + context.strokeRect(x1, y1, width, height + 3); + context.translate(x1, y1); + context.rotate(rad); + context.translate(-x1, -y1); + } + + public clone() { + let v = new VentShaft(); + v.x = this.x; + v.y = this.y; + v.angle = this.angle; + v.circle = this.circle; + v.ctrl = this.ctrl; + v.shift = this.shift; + v.diameter = this.diameter; + v.length = this.length; + return v; + } + + public distance(x1: number, x2: number, y1: number, y2: number) { + let a = x1 - x2; + let b = y1 - y2; + return Math.sqrt(a * a + b * b); + } + + public getSnapPoints() { + let snaps: SnapPoint[] = []; + snaps.push({ + x: this.x, + y: this.y, + angle: this.angle + 180, + occupant: undefined, + startPoint: true + }); + if (snaps[0].angle > 360) snaps[0].angle = snaps[0].angle - 360; + + let { x: endx, y: endy } = this.getEndXAndY(); + snaps.push({ + x: endx, + y: endy, + angle: this.angle, + occupant: undefined, + startPoint: false + }); + return snaps; + } + + public snap = (p: Placeable, snapDistance: number): void => { + snapDistance = snapDistance ? snapDistance : 12; + + for (let i = 0; i < p.getSnapPoints().length; i++) { + let point = p.getSnapPoints()[i]; + if (point.startPoint === false) { + let d = this.distance(this.x, point.x, this.y, point.y); + if (d < snapDistance) { + this.x = point.x; + this.y = point.y; + this.angle = point.angle; + } + } + } + }; + + private calcAngle = (x: number, y: number) => { + var dx = this.x - x; + var dy = this.y - y; + var theta = Math.atan2(dy, -dx); // [0, Ⲡ] then [-Ⲡ, 0]; clockwise; 0° = east + theta *= 180 / Math.PI; // [0, 180] then [-180, 0]; clockwise; 0° = east + if (theta < 0) theta += 360; // [0, 360]; clockwise; 0° = east + return theta; + }; + + public drag = (x: number, y: number, scale = 1) => { + if (this.circle.selected) { + if (this.shift) { + let dx = x - this.x; + let dy = y - this.y; + this.length = Math.sqrt(dx * dx + dy * dy); + + if (!this.ctrl) this.length = Math.round(this.length / 16) * 16; + + // Rotate 180 instead of having a negative length + if (dx < 0) { + if (dy > 0) { + if (!(this.angle > 180 && this.angle < 270)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } else if (!(this.angle > 90 && this.angle < 270)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } else { + if (dy > 0) { + if (!(this.angle < 360 && this.angle > 180)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } else if (!(this.angle > 0 && this.angle < 90)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } + } else { + this.angle = this.calcAngle(x, y); + let dx = x - this.x; + let dy = y - this.y; + this.length = Math.sqrt(dx * dx + dy * dy); + if (!this.ctrl) { + this.angle = Math.round(this.angle / 11.25) * 11.25; + this.length = Math.round(this.length / 16) * 16; + } + } + this.oldAngle = this.angle; + } else { + this.x = x - this.mouseOffsetX; + this.y = y - this.mouseOffsetY; + } + }; + + public endX(): number { + let rad = this.angle * (Math.PI / 180); + return this.x + this.length * Math.cos(rad); + } + + public endY(): number { + let rad = this.angle * (Math.PI / 180); + return this.y - this.length * Math.sin(rad); + } + + public getEndAngle(): number { + return this.angle; + } + + setDirection(direction: number): void {} + + setAngle(angle: number, oldAngle?: number) { + this.angle = angle; + if (oldAngle) this.oldAngle = oldAngle; + } +} diff --git a/src/ventilation/drawable/VentTBreak.ts b/src/ventilation/drawable/VentTBreak.ts new file mode 100644 index 0000000..4303e88 --- /dev/null +++ b/src/ventilation/drawable/VentTBreak.ts @@ -0,0 +1,483 @@ +import { pond } from "protobuf-ts/pond"; +import { Placeable } from "./Placeable"; +import { Circle } from "./Circle"; +import { SnapPoint, getRectangularPoints, isPointInRectangle } from "./geometry"; + +export class VentTBreak implements Placeable { + public debug = false; + public x = 0; + public y = 0; + public angle = 0; + public ctrl = false; + public shift = false; + public selected = false; + public type = pond.PlaceableType.PLACEABLE_TYPE_SHAFT; + public direction = 0; + public subtype = pond.ShaftType.SHAFT_TYPE_T_BREAK; + + private length = 192; + private diameter = 64; + + private circle = Circle.create(0, 0, 0); + private break_circle = Circle.create(0, 0, 0); + private mouseOffsetX = 0; + private mouseOffsetY = 0; + private oldAngle = 0; + private snapped = false; + + private break_length = 64; + + public magnitude(): number { + return this.length; + } + + public radius(): number { + return this.break_length; + } + + public static fromPond(shaft: pond.Placeable) { + let v = new VentTBreak(); + v.x = shaft.x; + v.y = shaft.y; + v.angle = shaft.angle; + v.length = shaft.magnitude; + v.direction = shaft.direction; + v.break_circle.selected = false; + v.break_length = shaft.radius; + + document.body.addEventListener("keydown", function(event) { + if (event.key === "Control") { + v.ctrl = true; + } else if (event.key === "Shift") { + v.shift = true; + } + }); + + document.body.addEventListener("keyup", function(event) { + if (event.key === "Control") { + v.ctrl = false; + } else if (event.key === "Shift") { + v.shift = false; + } + }); + + return v; + } + + public static create(pb?: pond.Block, img?: HTMLImageElement): VentTBreak { + let v = new VentTBreak(); + //v.settings = pond.BlockSettings.fromObject(cloneDeep(or(pb?.settings, {}))); + + // For some reason, negative x and y values don't clone correctly + // Same goes for decimal values in angle + if (pb?.settings) { + v.x = pb.settings.x; + v.y = pb.settings.y; + v.angle = pb.settings.angle; + } + + document.body.addEventListener("keydown", function(event) { + if (event.key === "Control") { + v.ctrl = true; + } else if (event.key === "Shift") { + v.shift = true; + } + }); + + document.body.addEventListener("keyup", function(event) { + if (event.key === "Control") { + v.ctrl = false; + } else if (event.key === "Shift") { + v.shift = false; + } + }); + + return v; + } + + public draw( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale?: number + ) { + if (!scale) scale = 1; + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + let width = this.length; + let height = this.diameter; + + //scale it + x = x * scale; + y = y * scale; + width = width * scale; + height = height * scale; + let break_length = this.break_length * scale; + + let { x1, y1, x2, y2, x3, y3, x4, y4 } = getRectangularPoints(x, y, width, height, this.angle); + + context.beginPath(); + + let { x: bx, y: by } = this.getBreakOrigin(scale, offsetX, offsetY); + //context.arc(bx, by, 6, 0, Math.PI * 360) + + let break_angle = this.angle + 90; + if (this.direction === 1) break_angle = this.angle - 90; + let { x1: x5, y1: y5, x2: x6, y2: y6, x3: x7, y3: y7, x4: x8, y4: y8 } = getRectangularPoints( + bx, + by, + break_length, + height, + break_angle + ); + + if (this.direction === 0) { + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.moveTo(x4, y4); + context.lineTo(x8, y8); + context.lineTo(x7, y7); + context.moveTo(x6, y6); + context.lineTo(x5, y5); + context.lineTo(x3, y3); + } else if (this.direction === 1) { + context.moveTo(x3, y3); + context.lineTo(x4, y4); + context.moveTo(x1, y1); + context.lineTo(x5, y5); + context.lineTo(x6, y6); + context.moveTo(x7, y7); + context.lineTo(x8, y8); + context.lineTo(x2, y2); + } + + context.strokeStyle = "rgba(255, 255, 255, 1)"; + context.stroke(); + } + + public getEndXAndY = (scale = 1, offsetX = 0, offsetY = 0) => { + let x = this.endX(this.x, this.angle, this.length); + let y = this.endY(this.y, this.angle, this.length); + return { x: (x - offsetX) * scale, y: (y - offsetY) * scale }; + }; + + public clickCheck = (x: number, y: number, scale = 1) => { + let width = this.length; + let height = this.diameter; + + // Check end circle + ({ x: this.circle.x, y: this.circle.y } = this.getEndXAndY()); + this.circle.radius = height / 4; + if (this.circle.clickCheck(x, y, scale)) { + this.break_circle.selected = false; + return true; + } + + // Check break end circle + ({ x: this.break_circle.x, y: this.break_circle.y } = this.getBreakEnd()); + this.break_circle.radius = height / 4; + if (this.break_circle.clickCheck(x, y, scale)) { + this.circle.selected = false; + return true; + } + + // Check main rectangle + let p = getRectangularPoints(this.x, this.y, width, height, this.angle); + if (isPointInRectangle(x, y, p.x1, p.y1, p.x2, p.y2, p.x3, p.y3, p.x4, p.y4)) { + this.selected = true; + this.mouseOffsetX = x - this.x; + this.mouseOffsetY = y - this.y; + return true; + } + + // Check extension triangle + let { x: bx, y: by } = this.getBreakOrigin(); + //bx = bx * scale + //by = by * scale + let break_angle = this.angle + 90; + if (this.direction === 1) break_angle = this.angle - 90; + p = getRectangularPoints(bx, by, this.break_length, height, break_angle); + if (isPointInRectangle(x, y, p.x1, p.y1, p.x2, p.y2, p.x3, p.y3, p.x4, p.y4)) { + this.selected = true; + this.mouseOffsetX = x - this.x; + this.mouseOffsetY = y - this.y; + return true; + } + + this.selected = false; + if (this.snapped) { + this.oldAngle = this.angle; + this.snapped = false; + } + return false; + }; + + public highlight( + context: CanvasRenderingContext2D, + offsetX?: number, + offsetY?: number, + scale?: number + ) { + if (!scale) scale = 1; + let x = this.x - (offsetX ? offsetX : 0); + let y = this.y - (offsetY ? offsetY : 0); + let width = this.length; + let height = this.diameter; + + //scale it + x = x * scale; + y = y * scale; + width = width * scale; + height = (height + 3) * scale; + let break_length = this.break_length * scale; + + let { x1, y1, x2, y2, x3, y3, x4, y4 } = getRectangularPoints(x, y, width, height, this.angle); + + context.beginPath(); + + let { x: bx, y: by } = this.getBreakOrigin(scale, offsetX, offsetY); + //context.arc(bx, by, 6, 0, Math.PI * 360) + + let break_angle = this.angle + 90; + if (this.direction === 1) break_angle = this.angle - 90; + let { x1: x5, y1: y5, x2: x6, y2: y6, x3: x7, y3: y7, x4: x8, y4: y8 } = getRectangularPoints( + bx, + by, + break_length, + height, + break_angle + ); + + if (this.direction === 0) { + context.lineTo(x1, y1); + context.lineTo(x2, y2); + context.moveTo(x4, y4); + context.lineTo(x8, y8); + context.lineTo(x7, y7); + context.lineTo(x6, y6); + context.lineTo(x5, y5); + context.lineTo(x3, y3); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.lineTo(x3, y3); + context.moveTo(x1, y1); + context.lineTo(x4, y4); + } else if (this.direction === 1) { + context.lineTo(x3, y3); + context.lineTo(x4, y4); + context.lineTo(x1, y1); + context.lineTo(x5, y5); + context.lineTo(x6, y6); + context.lineTo(x7, y7); + context.lineTo(x8, y8); + context.lineTo(x2, y2); + context.lineTo(x3, y3); + } + + context.strokeStyle = "rgba(247, 202, 24, 1)"; + context.stroke(); + + context.strokeStyle = "rgba(225, 225, 225, 1)"; + let { x: endx, y: endy } = this.getEndXAndY(scale, offsetX, offsetY); + this.circle.x = endx; + this.circle.y = endy; + this.circle.radius = height / 4; + this.circle.draw(context); + + let { x: cx, y: cy } = this.getBreakEnd(scale, offsetX, offsetY); + this.break_circle.x = cx; + this.break_circle.y = cy; + this.break_circle.radius = height / 4; + this.break_circle.draw(context); + } + + public clone() { + let v = new VentTBreak(); + v.x = this.x; + v.y = this.y; + v.angle = this.angle; + v.circle = this.circle; + v.break_circle = this.break_circle; + v.break_length = this.break_length; + v.ctrl = this.ctrl; + v.shift = this.shift; + v.diameter = this.diameter; + v.length = this.length; + return v; + } + + public distance(x1: number, x2: number, y1: number, y2: number) { + let a = x1 - x2; + let b = y1 - y2; + return Math.sqrt(a * a + b * b); + } + + public getSnapPoints = () => { + let snaps: SnapPoint[] = []; + snaps.push({ + x: this.x, + y: this.y, + angle: this.angle + 180, + startPoint: true, + occupant: undefined + }); + if (snaps[0].angle > 360) snaps[0].angle = snaps[0].angle - 360; + + let { x: endx, y: endy } = this.getEndXAndY(); + snaps.push({ + x: endx, + y: endy, + angle: this.getEndAngle(), + occupant: undefined, + startPoint: false + }); + + let { x: bx, y: by } = this.getBreakEnd(); + let ang = this.angle + 90; + if (this.direction === 1) ang = this.angle - 90; + snaps.push({ + x: bx, + y: by, + angle: ang, + occupant: undefined, + startPoint: false + }); + if (snaps[2].angle < 0) snaps[2].angle = snaps[2].angle + 360; + + return snaps; + }; + + public snap = (p: Placeable, snapDistance: number): void => { + snapDistance = snapDistance ? snapDistance : 12; + p.getSnapPoints().forEach(point => { + if (!point.startPoint && this.distance(this.x, point.x, this.y, point.y) < snapDistance) { + this.x = point.x; + this.y = point.y; + this.angle = point.angle; + } + }); + }; + + private calcAngle = (x: number, y: number) => { + var dx = this.x - x; + var dy = this.y - y; + var theta = Math.atan2(dy, -dx); // [0, Ⲡ] then [-Ⲡ, 0]; clockwise; 0° = east + theta *= 180 / Math.PI; // [0, 180] then [-180, 0]; clockwise; 0° = east + if (theta < 0) theta += 360; // [0, 360]; clockwise; 0° = east + return theta; + }; + + public drag = (x: number, y: number, scale = 1) => { + if (this.circle.selected) { + if (this.shift) { + let dx = x - this.x; + let dy = y - this.y; + this.length = Math.sqrt(dx * dx + dy * dy); + + if (!this.ctrl) this.length = Math.round(this.length / 16) * 16; + + // Rotate 180 instead of having a negative length + if (dx < 0) { + if (dy > 0) { + if (!(this.angle > 180 && this.angle < 270)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } else if (!(this.angle > 90 && this.angle < 270)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } else { + if (dy > 0) { + if (!(this.angle < 360 && this.angle > 180)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } else if (!(this.angle > 0 && this.angle < 90)) { + this.angle += 180; + if (this.angle > 360) this.angle -= 360; + } + } + } else { + this.angle = this.calcAngle(x, y); + let dx = x - this.x; + let dy = y - this.y; + this.length = Math.sqrt(dx * dx + dy * dy); + if (!this.ctrl) { + this.angle = Math.round(this.angle / 11.25) * 11.25; + this.length = Math.round(this.length / 16) * 16; + } + } + this.oldAngle = this.angle; + } else if (this.break_circle.selected) { + let ang = this.angle; + + let { x: ox, y: oy } = this.getBreakOrigin(); + + let a = x - ox; + let b = y - oy; + let h = Math.sqrt(a * a + b * b); + + this.break_length = h; + if (!this.ctrl) this.break_length = Math.round(this.break_length / 16) * 16; + + let ang2 = Math.atan2(y - oy, x - ox) * (180 / Math.PI); + ang2 = (ang2 + 360) % 360; + ang2 = (360 - ang2) % 360; + let diff = (ang - ang2 + 360) % 360; + + // Set break length to 0 if you're not extending outward + if ((this.direction === 0 && diff < 180) || (this.direction === 1 && diff > 180)) { + this.break_length = 0; + } + } else { + this.x = x - this.mouseOffsetX; + this.y = y - this.mouseOffsetY; + } + }; + + public endX(x = this.x, angle = this.angle, length = this.length): number { + let rad = angle * (Math.PI / 180); + return x + length * Math.cos(rad); + } + + public endY(y = this.y, angle = this.angle, length = this.length): number { + let rad = angle * (Math.PI / 180); + return y - length * Math.sin(rad); + } + + private getBreakEnd(scale = 1, offsetX = 0, offsetY = 0) { + let rad = this.angle * (Math.PI / 180); + let rad2 = (this.angle + 90) * (Math.PI / 180); + if (this.direction === 1) rad2 = (this.angle - 90) * (Math.PI / 180); + let cx = this.x + (Math.cos(rad) * this.length) / 2; + let cy = this.y - (Math.sin(rad) * this.length) / 2; + cx = cx + Math.cos(rad2) * (this.break_length + this.diameter / 2); + cy = cy - Math.sin(rad2) * (this.break_length + this.diameter / 2); + return { x: (cx - offsetX) * scale, y: (cy - offsetY) * scale }; + } + + private getBreakOrigin(scale = 1, offsetX = 0, offsetY = 0) { + let rad = this.angle * (Math.PI / 180); + let rad2 = (this.angle + 90) * (Math.PI / 180); + if (this.direction === 1) rad2 = (this.angle - 90) * (Math.PI / 180); + let cx = this.x + (Math.cos(rad) * this.length) / 2; + let cy = this.y - (Math.sin(rad) * this.length) / 2; + cx = cx + Math.cos(rad2) * (this.diameter / 2); + cy = cy - Math.sin(rad2) * (this.diameter / 2); + return { x: (cx - offsetX) * scale, y: (cy - offsetY) * scale }; + } + + public getEndAngle(): number { + return this.angle; + } + + setDirection(direction: number): void { + this.direction = direction; + } + + setAngle(angle: number, oldAngle?: number) { + this.angle = angle; + if (oldAngle) this.oldAngle = oldAngle; + } +} diff --git a/src/ventilation/drawable/geometry.ts b/src/ventilation/drawable/geometry.ts new file mode 100644 index 0000000..a810512 --- /dev/null +++ b/src/ventilation/drawable/geometry.ts @@ -0,0 +1,63 @@ +import { Placeable } from "./Placeable"; + +export function isPointInRectangle( + x: number, + y: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + x4: number, + y4: number +) { + const minX = Math.min(x1, x2, x3, x4); + const maxX = Math.max(x1, x2, x3, x4); + const minY = Math.min(y1, y2, y3, y4); + const maxY = Math.max(y1, y2, y3, y4); + + if (x >= minX && x <= maxX && y >= minY && y <= maxY) { + const d1 = (x - x1) * (y2 - y1) - (y - y1) * (x2 - x1); + const d2 = (x - x2) * (y3 - y2) - (y - y2) * (x3 - x2); + const d3 = (x - x3) * (y4 - y3) - (y - y3) * (x4 - x3); + const d4 = (x - x4) * (y1 - y4) - (y - y4) * (x1 - x4); + + return (d1 >= 0 && d2 >= 0 && d3 >= 0 && d4 >= 0) || (d1 <= 0 && d2 <= 0 && d3 <= 0 && d4 <= 0); + } + + return false; +} + +export function getRectangularPoints( + x: number, + y: number, + width: number, + height: number, + angle: number +) { + let x1 = x + (Math.cos((angle - 90) * (Math.PI / 180)) * height) / 2; + let y1 = y - (Math.sin((angle - 90) * (Math.PI / 180)) * height) / 2; + let x2 = x1 + Math.cos(angle * (Math.PI / 180)) * width; + let y2 = y1 - Math.sin(angle * (Math.PI / 180)) * width; + let x3 = x2 + Math.cos((angle + 90) * (Math.PI / 180)) * height; + let y3 = y2 - Math.sin((angle + 90) * (Math.PI / 180)) * height; + let x4 = x3 + Math.cos((angle - 180) * (Math.PI / 180)) * width; + let y4 = y3 - Math.sin((angle - 180) * (Math.PI / 180)) * width; + /*console.log((angle - 90) * (Math.PI / 180)) + console.log((angle) * (Math.PI / 180)) + console.log((angle + 90) * (Math.PI / 180)) + console.log("(" + x1 + ", " + y1 + ")") + console.log("(" + x2 + ", " + y2 + ")") + console.log("(" + x3 + ", " + y3 + ")") + console.log("(" + x4 + ", " + y4 + ")")*/ + return { x1, y1, x2, y2, x3, y3, x4, y4 }; +} + +export interface SnapPoint { + x: number; + y: number; + angle: number; + startPoint: boolean; + occupant: Placeable | undefined; +} diff --git a/src/ventilation/drawers/ComponentCards.tsx b/src/ventilation/drawers/ComponentCards.tsx new file mode 100644 index 0000000..0b6a6d4 --- /dev/null +++ b/src/ventilation/drawers/ComponentCards.tsx @@ -0,0 +1,364 @@ +import { useComponentAPI, useMineAPI, useThemeType } from "hooks"; +import React, { useEffect, useState } from "react"; +import { Component } from "models"; +import { + Avatar, + Box, + Card, + CardContent, + Checkbox, + CircularProgress, + createStyles, + Grid, + makeStyles, + Theme, + Typography, + useTheme +} from "@material-ui/core"; +import CardHeader from "@material-ui/core/CardHeader"; +import { getContextKeys, getContextTypes } from "pbHelpers/Context"; +import { pond } from "protobuf-ts/pond"; +import { GetComponentIcon } from "pbHelpers/ComponentType"; +import EventBlocker from "common/EventBlocker"; +import UnitMeasurementSummary from "component/UnitMeasurementSummary"; +import { UnitMeasurement } from "models/UnitMeasurement"; +import { useGlobalState } from "providers"; +import DelayedTextField from "common/DelayedTextInput"; +import { useHistory } from "react-router"; + +interface Props { + deviceId: number; + showMobile?: boolean; + mineComponents: Component[]; + setMineComponents: React.Dispatch>; + mineComponentPreferences: Map; + setMineComponentPreferences: React.Dispatch< + React.SetStateAction> + >; + keys?: string[]; + types?: string[]; +} + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + avatarIcon: { + width: theme.spacing(3), + height: theme.spacing(3) + }, + card: { + position: "relative", + display: "flex", + flexDirection: "column", + overflow: "visible", + width: "100%" + }, + cardContent: { + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + alignItems: "center", + padding: theme.spacing(1), + paddingTop: 0 + }, + onHover: { + "&:hover": { + textDecoration: "underline", + cursor: "pointer" + } + } + }) +); + +export default function ComponentCards(props: Props) { + const { + deviceId, + showMobile, + setMineComponents, + mineComponents, + mineComponentPreferences, + setMineComponentPreferences + } = props; + const componentAPI = useComponentAPI(); + const mineAPI = useMineAPI(); + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(false); + const [firstLoad, setFirstLoad] = useState(true); + const themeType = useThemeType(); + const classes = useStyles(); + const [{ user }] = useGlobalState(); + const theme = useTheme(); + const history = useHistory(); + + const keys = props.keys ? props.keys : getContextKeys(); + const types = props.types ? props.types : getContextTypes(); + + const removeComponent = (device: string, component: Component) => { + let mine = ""; + types.forEach((type, index) => { + if (type === "mine") mine = keys[index]; + }); + mineAPI.addComponent(mine, device, component.key(), [pond.Permission.PERMISSION_INVALID]); + }; + + const addComponent = (device: string, component: Component) => { + let mine = ""; + types.forEach((type, index) => { + if (type === "mine") mine = keys[index]; + }); + mineAPI + .addComponent(mine, deviceId.toString(), component.key(), [ + pond.Permission.PERMISSION_READ, + pond.Permission.PERMISSION_SHARE, + pond.Permission.PERMISSION_WRITE, + pond.Permission.PERMISSION_USERS + ]) + .then(() => { + let prefs = getPreferences(component); + mineComponentPreferences.set(component.key(), prefs); + setMineComponentPreferences(new Map(mineComponentPreferences)); + let c = mineComponents; + c.push(component); + setMineComponents([...c]); + }); + }; + + const getPreferences = (component: Component) => { + let exists = mineComponents.find(comp => comp.key() === component.key()); + let prefs = mineComponentPreferences.get(component.key()); + if ((!prefs?.sensors || prefs.sensors.length < 1) && exists) { + prefs = pond.MineComponentPreferences.create(); + prefs.sensors = []; + if (component.lastMeasurement.length > 0) { + if (component.lastMeasurement[0].values) + component.lastMeasurement[0].values[0].values.forEach((v, i) => { + let sensor = pond.MineSensorPreferences.create(); + sensor.enabled = false; + sensor.x = 20; + sensor.y = 20; + sensor.nickname = component.name(); + if (component.lastMeasurement[0].values[0].values.length > 1) { + sensor.nickname = "Sensor " + i; + } + prefs!.sensors.push(sensor); + }); + mineComponentPreferences.set(component.key(), prefs); + setMineComponentPreferences(new Map(mineComponentPreferences)); + } + } + return prefs!; + }; + + useEffect(() => { + let k = [...keys]; + k.push(deviceId.toString()); + let t = [...types]; + t.push("device"); + setLoading(true); + componentAPI + .list(deviceId, false, k, t, true) + .then(resp => { + if (loading) return; + if (!resp.data.components) return; + setLoading(true); + let components: Component[] = []; + let cards: JSX.Element[] = []; + + resp.data.components.forEach((comp, index) => { + if (!comp) return; + let component = Component.create(comp); + + // Ignore component without data + if (!component.lastMeasurement) return; + let measurement: UnitMeasurement[] = []; + if (component.lastMeasurement.forEach) { + component.lastMeasurement.forEach(m => { + measurement.push(UnitMeasurement.any(m, user)); + }); + } + components.push(component); + const exists = mineComponents.find(comp => comp.key() === component.key()); + const componentIcon = GetComponentIcon(component.type(), component.subType(), themeType); + let prefs = getPreferences(component); + cards.push( +
+ + + ) : ( + + ) + } + title={ + component.name() ? ( + { + history.replace( + history.location.pathname + + "/devices/" + + deviceId + + "/components/" + + component.key() + ); + }}> + {component.name()} + + ) : ( + "No Name" + ) + } + subheader={ + + } + action={ + + { + if (!exists) { + addComponent(deviceId.toString(), component); + } else { + removeComponent(deviceId.toString(), component); + } + }} + /> + + } + /> + {exists && ( + + + {prefs?.sensors.map((sensor, i) => { + let reading = UnitMeasurement.convertLastMeasurementByNode(measurement, i); + return ( + 0 ? 10 : 0 + }}> + + + { + let prefs = mineComponentPreferences.get(component.key()); + if (prefs) { + prefs.sensors[i].nickname = e.toString(); + mineComponentPreferences.set(component.key(), prefs); + setMineComponentPreferences( + new Map(mineComponentPreferences) + ); + } + }} + delayMS={2000} + size="small" + inputProps={{ + style: { fontSize: 12 } + }} + disabled={loading} + /> + + + + + + + + {"(" + sensor.x + ", " + sensor.y + ")"} + + + + + + + { + let prefs = mineComponentPreferences.get(component.key()); + if (sensor.enabled) { + if (prefs) { + prefs.sensors[i].enabled = false; + sensor.enabled = false; + mineComponentPreferences.set(component.key(), prefs); + setMineComponentPreferences( + new Map(mineComponentPreferences) + ); + } + } else { + if (prefs) { + sensor.enabled = true; + mineComponentPreferences.set(component.key(), prefs); + setMineComponentPreferences( + new Map(mineComponentPreferences) + ); + } + } + }} + /> + + + + ); + })} + + + )} + +
+ ); + }); + setCards(cards); + //setComponents(components); + }) + .finally(() => { + setFirstLoad(false); + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deviceId, componentAPI, showMobile, keys, types]); + + return ( + + {firstLoad ? ( +
+ +
+ ) : ( + cards + )} +
+ ); +} diff --git a/src/ventilation/drawers/DeviceDrawer.tsx b/src/ventilation/drawers/DeviceDrawer.tsx new file mode 100644 index 0000000..73275e4 --- /dev/null +++ b/src/ventilation/drawers/DeviceDrawer.tsx @@ -0,0 +1,295 @@ +import { + Box, + Card, + Checkbox, + CircularProgress, + createStyles, + Divider, + Grid, + IconButton, + makeStyles, + MenuItem, + Select, + Tab, + Tabs, + Theme, + Typography +} from "@material-ui/core"; +import DeviceIcon from "assets/editor/device.png"; +import React, { useEffect, useState } from "react"; +import { pond } from "protobuf-ts/pond"; +import { useDeviceAPI, useMineAPI } from "hooks"; +import LeftIcon from "@material-ui/icons/KeyboardArrowLeft"; +import RightIcon from "@material-ui/icons/KeyboardArrowRight"; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + topText: { + //textAlign: "center", + //alignContent: "center" + }, + bottom: { + display: "flex", + alignItems: "center", + width: "100%", + paddingRight: theme.spacing(1), + paddingLeft: theme.spacing(1), + marginTop: theme.spacing(1) + }, + bottomButtons: { + marginLeft: "auto", + justifyContent: "center", + alignContent: "center" + }, + divider: { + margin: theme.spacing(2) + }, + topTextContainer: { + width: "100%", + marginLeft: "auto", + marginRight: "auto", + padding: theme.spacing(1), + marginBottom: 0, + paddingBottom: 0 + }, + deviceCard: { + display: "flex", + flexDirection: "row", + padding: theme.spacing(1), + marginTop: theme.spacing(1), + height: theme.spacing(8) + }, + skeletonCard: { + marginTop: theme.spacing(1), + height: theme.spacing(8), + borderRadius: "6px" + }, + fanIcon: { + height: "100%", + objectFit: "contain" + }, + iconContainer: { + height: "100%", + width: theme.spacing(8) + }, + deviceInfo: { + display: "block", + margin: "auto", + marginLeft: theme.spacing(1), + padding: 0 + }, + checkbox: { + marginLeft: "auto", + marginTop: "auto", + marginBottom: "auto" + }, + tab: { + width: "50%", + maxWidth: "50%" + } + }); +}); + +interface Props { + addBlockRef: any; + selectedDevices: pond.Device[]; + setSelectedDevices: any; + mine: pond.MineSettings; + setMine: React.Dispatch>; +} + +export default function DeviceDrawer(props: Props) { + //const isMobile = useMobile(); + const { selectedDevices, setSelectedDevices, mine } = props; + const classes = useStyles(); + //const addBlockRef = props.addBlockRef; + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [limit, setLimit] = useState(5); + const [selectedIDs, setSelectedIDs] = useState([]); + const deviceAPI = useDeviceAPI(); + const mineAPI = useMineAPI(); + const [tab, setTab] = React.useState(0); + + useEffect(() => { + let ids: number[] = []; + selectedDevices.forEach(dev => { + if (dev.settings) { + ids.push(dev.settings.deviceId); + } + }); + setSelectedIDs([...ids]); + }, [selectedDevices]); + + useEffect(() => { + setLoading(true); + deviceAPI.list(limit, offset).then(resp => { + setDevices(resp.data.devices); + setTotal(resp.data.total); + setLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [limit, offset, deviceAPI]); + + const back = () => { + if (offset - limit < 0) { + setOffset(0); + } else { + setOffset(offset - limit); + } + }; + + const forward = () => { + if (offset + limit > total) { + setOffset(total - limit); + } else { + setOffset(offset + limit); + } + }; + + const toggleDevice = (device: pond.Device) => { + let id = device.settings ? device.settings.deviceId : 0; + const index = selectedIDs.indexOf(id); + if (index > -1) { + if (device.settings && mine.name !== "") { + mineAPI.addDevice(mine.key, device.settings.deviceId.toString(), []).then(resp => { + selectedIDs.splice(index, 1); + let d = selectedDevices; + let deviceIndex = 0; + d.forEach((device, i) => { + let key = device.settings ? device.settings.deviceId : 0; + if (key === id) { + deviceIndex = i; + } + }); + d.splice(deviceIndex, 1); + setSelectedDevices([...d]); + }); + } + } else { + if (device.settings && mine) { + mineAPI + .addDevice(mine.key, device.settings.deviceId.toString(), [ + pond.Permission.PERMISSION_READ, + pond.Permission.PERMISSION_SHARE, + pond.Permission.PERMISSION_WRITE, + pond.Permission.PERMISSION_USERS + ]) + .then(resp => { + selectedIDs.push(id); + let d = selectedDevices; + d.push(device); + setSelectedDevices([...d]); + }); + } + } + }; + + const yourDevices = () => { + if (loading) return ; + + return devices.map((device: pond.Device, i) => { + let name = device.settings?.name + ? device.settings?.name + : "Device " + device.settings?.deviceId; + return ( + +
+ {device.settings?.name} +
+
+ + {name} + +
+ -1 : false} + onChange={() => device.settings && toggleDevice(device)} + /> +
+ ); + }); + }; + + const attachedDevices = () => { + if (loading) return ; + + return selectedDevices.map((device: pond.Device) => { + let name = device.settings?.name + ? device.settings?.name + : "Device " + device.settings?.deviceId; + return ( + +
+ {device.settings?.name} +
+
+ + {name} + +
+ -1 : false} + onChange={() => device.settings && toggleDevice(device)} + /> +
+ ); + }); + }; + + const bottom = () => { + return ( +
+ {offset + 1} - {devices.length + offset} of {total} +
+ + + + + + + + + +
+
+ ); + }; + + const handleTabChange = (event: React.ChangeEvent<{}>, newValue: number) => { + setTab(newValue); + }; + + return ( + +
+ + Devices you have access to are listed below. Click the check box to gain access to the + device's sensor in the editor. Devices that you and others have added can be seen in the + second tab. + +
+ + + + + + + + {tab === 0 ? yourDevices() : attachedDevices()} + {bottom()} +
+ ); +} diff --git a/src/ventilation/drawers/DuctingDrawer.tsx b/src/ventilation/drawers/DuctingDrawer.tsx new file mode 100644 index 0000000..c0b7ef7 --- /dev/null +++ b/src/ventilation/drawers/DuctingDrawer.tsx @@ -0,0 +1,181 @@ +import { Card, createStyles, Divider, makeStyles, Theme, Typography } from "@material-ui/core"; +import InlineIcon from "assets/editor/inlineCentrifugal.png"; +import FullIcon from "assets/editor/fullCentrifugal.png"; +import LowIcon from "assets/editor/lowCentrifugal.png"; +import Fan1Icon from "assets/editor/fan1.png"; +import Fan2Icon from "assets/editor/fan2.png"; +import Fan3Icon from "assets/editor/fan3.png"; +import AddIcon from "@material-ui/icons/Add"; +import React from "react"; +import { Block } from "ventilation/drawable/Block"; +import { pond } from "protobuf-ts/pond"; +import { VentShaft } from "ventilation/drawable/VentShaft"; +import { VentCorner } from "ventilation/drawable/VentCorner"; +import { VentTBreak } from "ventilation/drawable/VentTBreak"; + +const ducts = [ + { + name: "Duct Line", + img: InlineIcon, + editorImg: Fan1Icon, + beta: false + }, + { + name: "Corner", + img: FullIcon, + editorImg: Fan2Icon, + beta: false + }, + { + name: "T-Break", + img: LowIcon, + editorImg: Fan3Icon, + beta: false + }, + { + name: "Y-Break (coming soon)", + img: LowIcon, + editorImg: Fan3Icon, + beta: true + } +]; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + topText: { + //textAlign: "center", + //alignContent: "center" + }, + divider: { + margin: theme.spacing(2) + }, + topTextContainer: { + width: "100%", + marginLeft: "auto", + marginRight: "auto", + padding: theme.spacing(1), + marginBottom: 0, + paddingBottom: 0 + }, + fanCard: { + display: "flex", + flexDirection: "row", + padding: theme.spacing(1), + marginTop: theme.spacing(1), + height: theme.spacing(10) + }, + fanIcon: { + height: "100%", + objectFit: "contain" + }, + fanIconContainer: { + height: "100%", + width: theme.spacing(8) + }, + ductInfo: { + display: "block", + marginLeft: theme.spacing(1), + marginTop: "auto", + marginBottom: "auto" + }, + addIcon: { + //justifySelf: "flex-end", + marginLeft: "auto", + marginTop: "auto", + marginBottom: "auto", + width: theme.spacing(3), + height: theme.spacing(3), + borderRadius: theme.spacing(1), + "&:hover": { + cursor: "pointer", + background: "rgba(200,200,200, 0.25)" + } + } + }); +}); + +interface Props { + addBlockRef: any; +} + +export default function DuctingDrawer(props: Props) { + //const isMobile = useMobile(); + //const { isOpen, children } = props; + const classes = useStyles(); + const addBlockRef = props.addBlockRef; + + const addDuct = () => { + let block = VentShaft.create({ + settings: { + x: 64, + y: 64, + width: 64, + height: 64, + angle: 0 + } + } as pond.Block); + addBlockRef.current(block); + }; + + const addTBreak = () => { + let block = VentTBreak.create({ + settings: { + x: 64, + y: 64, + width: 64, + height: 64, + angle: 0 + } + } as pond.Block); + addBlockRef.current(block); + }; + + const addCorner = () => { + let block = VentCorner.create(64, 64, 128, 1 * Math.PI); + addBlockRef.current(block); + }; + + return ( + +
+ + Place a duct into the editor. If a duct or fan is selected in the editor, the new duct + will be placed in line with it. + +
+ + {ducts.map(duct => { + return ( + +
+ {duct.name} +
+
+ + {duct.name} + +
+ {!duct.beta && duct.name === "Duct Line" && ( + (addBlockRef.current ? addDuct() : undefined)} + /> + )} + {!duct.beta && duct.name === "Corner" && ( + (addBlockRef.current ? addCorner() : undefined)} + /> + )} + {!duct.beta && duct.name === "T-Break" && ( + (addBlockRef.current ? addTBreak() : undefined)} + /> + )} +
+ ); + })} +
+ ); +} diff --git a/src/ventilation/drawers/FansDrawer.tsx b/src/ventilation/drawers/FansDrawer.tsx new file mode 100644 index 0000000..fcc7fcc --- /dev/null +++ b/src/ventilation/drawers/FansDrawer.tsx @@ -0,0 +1,141 @@ +import { Card, createStyles, Divider, makeStyles, Theme, Typography } from "@material-ui/core"; +import InlineIcon from "assets/editor/inlineCentrifugal.png"; +import FullIcon from "assets/editor/fullCentrifugal.png"; +import LowIcon from "assets/editor/lowCentrifugal.png"; +import Fan1Icon from "assets/editor/fan1.png"; +import Fan2Icon from "assets/editor/fan2.png"; +import Fan3Icon from "assets/editor/fan3.png"; +import AddIcon from "@material-ui/icons/Add"; +import React from "react"; +import { Fan } from "ventilation/drawable/Fan"; + +const fans = [ + { + name: "In-Line Centrifugal", + img: InlineIcon, + mfg: "", + model: "Unknown", + hp: "500HP", + editorImg: Fan1Icon + }, + { + name: "Full Centrifugal", + img: FullIcon, + mfg: "", + model: "Unknown", + hp: "1000HP", + editorImg: Fan2Icon + }, + { + name: "Low Speed Centrifugal", + img: LowIcon, + mfg: "", + model: "Unknown", + hp: "250HP", + editorImg: Fan3Icon + } +]; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + topText: { + //textAlign: "center", + //alignContent: "center" + }, + divider: { + margin: theme.spacing(2) + }, + topTextContainer: { + width: "100%", + marginLeft: "auto", + marginRight: "auto", + padding: theme.spacing(1), + marginBottom: 0, + paddingBottom: 0 + }, + fanCard: { + display: "flex", + flexDirection: "row", + padding: theme.spacing(1), + marginTop: theme.spacing(1), + height: theme.spacing(10) + }, + fanIcon: { + height: "100%", + objectFit: "contain" + }, + fanIconContainer: { + height: "100%", + width: theme.spacing(8) + }, + fanInfo: { + display: "block", + marginLeft: theme.spacing(1) + }, + addIcon: { + //justifySelf: "flex-end", + marginLeft: "auto", + marginTop: "auto", + marginBottom: "auto", + width: theme.spacing(3), + height: theme.spacing(3), + borderRadius: theme.spacing(1), + "&:hover": { + cursor: "pointer", + background: "rgba(200,200,200, 0.25)" + } + } + }); +}); + +interface Props { + addBlockRef: any; +} + +export default function FansDrawer(props: Props) { + //const isMobile = useMobile(); + //const { isOpen, children } = props; + const classes = useStyles(); + const addBlockRef = props.addBlockRef; + + const addFan = (subtype: number) => { + let fan = Fan.create(0, 0, 32, subtype); + addBlockRef.current(fan); + }; + + return ( + +
+ + Select a fan from the list below to place it in the Ventilation Editor + +
+ + {fans.map((fan, index) => { + return ( + +
+ {fan.name} +
+
+ + {fan.name} + + + + Model: {fan.model} + + + Power: {fan.hp} + +
+ (addBlockRef.current ? addFan(index + 1) : undefined)} + /> +
+ ); + })} +
+ ); +} diff --git a/src/ventilation/drawers/SensorDrawer.tsx b/src/ventilation/drawers/SensorDrawer.tsx new file mode 100644 index 0000000..e233696 --- /dev/null +++ b/src/ventilation/drawers/SensorDrawer.tsx @@ -0,0 +1,215 @@ +import { + Collapse, + createStyles, + Divider, + Grid, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemText, + makeStyles, + MenuItem, + Select, + Theme, + Typography, + useTheme +} from "@material-ui/core"; +import React, { useState } from "react"; +import { pond } from "protobuf-ts/pond"; +import LeftIcon from "@material-ui/icons/KeyboardArrowLeft"; +import RightIcon from "@material-ui/icons/KeyboardArrowRight"; +import { ExpandLess, ExpandMore } from "@material-ui/icons"; +import DeviceIcon from "assets/editor/device.png"; +import { ImgIcon } from "common/ImgIcon"; +import ComponentCards from "./ComponentCards"; +import { Component } from "models"; + +const useStyles = makeStyles((theme: Theme) => { + return createStyles({ + topText: { + //textAlign: "center", + //alignContent: "center" + }, + bottom: { + display: "flex", + alignItems: "center", + width: "100%", + paddingRight: theme.spacing(1), + paddingLeft: theme.spacing(1), + marginTop: theme.spacing(1) + }, + bottomButtons: { + marginLeft: "auto", + justifyContent: "center", + alignContent: "center" + }, + divider: { + margin: theme.spacing(2) + }, + topTextContainer: { + width: "100%", + marginLeft: "auto", + marginRight: "auto", + padding: theme.spacing(1), + marginBottom: 0, + paddingBottom: 0 + }, + deviceCard: { + display: "flex", + flexDirection: "row", + padding: theme.spacing(1), + marginTop: theme.spacing(1), + height: theme.spacing(8) + }, + skeletonCard: { + marginTop: theme.spacing(1), + height: theme.spacing(8), + borderRadius: "6px" + }, + fanIcon: { + height: "100%", + objectFit: "contain" + }, + iconContainer: { + height: "100%", + width: theme.spacing(8) + }, + deviceInfo: { + display: "block", + margin: "auto", + marginLeft: theme.spacing(1), + padding: 0 + }, + checkbox: { + marginLeft: "auto", + marginTop: "auto", + marginBottom: "auto" + } + }); +}); + +interface Props { + addBlockRef: any; + selectedDevices: pond.Device[]; + setSelectedDevices: any; + mineComponents: Component[]; + setMineComponents: React.Dispatch>; + mineComponentPreferences: Map; + setMineComponentPreferences: React.Dispatch< + React.SetStateAction> + >; + mineKey: string; +} + +export default function SensorDrawer(props: Props) { + //const isMobile = useMobile(); + const { + selectedDevices, + mineComponents, + setMineComponents, + mineKey, + mineComponentPreferences, + setMineComponentPreferences + } = props; + const classes = useStyles(); + const [offset, setOffset] = useState(0); + const [limit, setLimit] = useState(5); + const [expanded, setExpanded] = useState(0); + const theme = useTheme(); + + const back = () => { + if (offset - limit < 0) { + setOffset(0); + } else { + setOffset(offset - limit); + } + }; + + const forward = () => { + if (offset + limit > selectedDevices.length) { + setOffset(selectedDevices.length - limit); + } else { + setOffset(offset + limit); + } + }; + + return ( + +
+ + Devices selected for this vent system are listed below. Click them to view attached + sensors and components. + +
+ + + + {selectedDevices.map((device: pond.Device) => { + return ( + + { + if (device.settings?.deviceId === expanded) { + setExpanded(0); + } else { + setExpanded(device.settings ? device.settings.deviceId : 0); + } + }}> + + + + + {device.settings?.deviceId === expanded ? : } + + + {device.settings?.deviceId === expanded && ( + +
+ + + )} + +
+ + + ); + })} + +
+ {offset + 1} - {limit + offset} of {selectedDevices.length} +
+ + + + + + + + + +
+
+ + ); +}