diff --git a/package.json b/package.json index 0fdb08b..b609806 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "antd": "^3.21.3", "axios": "^0.19.0", "babel-plugin-import": "^1.12.0", + "color-interpolate": "^1.0.5", "connected-react-router": "^6.5.2", "deck.gl": "^7.2.2", "electron-updater": "^4.1.2", diff --git a/src/renderer/actions/inputEditor.js b/src/renderer/actions/inputEditor.js index 747c0b6..8f31c61 100644 --- a/src/renderer/actions/inputEditor.js +++ b/src/renderer/actions/inputEditor.js @@ -15,6 +15,8 @@ export const REQUEST_MAPDATA = 'REQUEST_MAPDATA'; export const RECEIVE_MAPDATA = 'RECEIVE_MAPDATA'; export const SET_SELECTED = 'SET_SELECTED'; export const UPDATE_INPUTDATA = 'UPDATE_INPUTDATA'; +export const UPDATE_YEARSCHEDULE = 'UPDATE_YEARSCHEDULE'; +export const UPDATE_DAYSCHEDULE = 'UPDATE_DAYSCHEDULE'; export const DELETE_BUILDINGS = 'DELETE_BUILDINGS'; export const SAVE_INPUTDATA = 'SAVE_INPUTDATA'; export const SAVE_INPUTDATA_SUCCESS = 'SAVE_INPUTDATA_SUCCESS'; @@ -38,69 +40,69 @@ export const fetchInputData = () => export const saveChanges = () => (dispatch, getState) => // eslint-disable-next-line no-undef new Promise((resolve, reject) => { - const { tables, geojsons, crs } = getState().inputData; + const { tables, geojsons, crs, schedules } = getState().inputData; dispatch( httpAction({ url: '/inputs/all-inputs', method: 'PUT', type: SAVE_INPUTDATA, - data: { tables, geojsons, crs }, + data: { tables, geojsons, crs, schedules }, onSuccess: data => resolve(data), onFailure: error => reject(error) }) ); }); -export const fetchBuildingSchedule = buildings => (dispatch, getState) => { - const toFetch = buildings.filter( - building => !Object.keys(getState().inputData.schedules).includes(building) +export const fetchBuildingSchedule = buildings => dispatch => { + dispatch({ type: REQUEST_BUILDINGSCHEDULE }); + let errors = {}; + const promises = buildings.map(building => + axios + .get(`http://localhost:5050/api/inputs/building-schedule/${building}`) + .then(resp => { + return { [building]: resp.data }; + }) + .catch(error => { + errors[building] = error.response.data; + }) ); - if (toFetch.length) { - dispatch({ type: REQUEST_BUILDINGSCHEDULE }); - let errors = {}; - const promises = buildings.map(building => - axios - .get(`http://localhost:5050/api/inputs/building-schedule/${building}`) - .then(resp => { - return { [building]: resp.data }; - }) - .catch(error => { - errors[building] = error.response.data; - }) - ); - // eslint-disable-next-line no-undef - return Promise.all(promises).then(values => { - if (Object.keys(errors).length) { - throw errors; - } else { - let out = {}; - for (const schedule of values) { - const building = Object.keys(schedule)[0]; - out[building] = schedule[building]; - } - dispatch({ - type: REQUEST_BUILDINGSCHEDULE_SUCCESS, - payload: out - }); - } - }); - } // eslint-disable-next-line no-undef - return Promise.resolve(); + return Promise.all(promises).then(values => { + if (Object.keys(errors).length) { + throw errors; + } else { + let out = {}; + for (const schedule of values) { + const building = Object.keys(schedule)[0]; + out[building] = schedule[building]; + } + dispatch({ + type: REQUEST_BUILDINGSCHEDULE_SUCCESS, + payload: out + }); + } + }); }; -export const discardChanges = () => dispatch => +export const discardChanges = () => (dispatch, getState) => // eslint-disable-next-line no-undef - new Promise((resolve, reject) => { - dispatch( - httpAction({ - url: '/inputs/all-inputs', - type: DISCARD_INPUTDATA_CHANGES, - onSuccess: data => resolve(data), - onFailure: error => reject(error) - }) - ); - }); + Promise.all([ + // eslint-disable-next-line no-undef + new Promise((resolve, reject) => { + dispatch( + httpAction({ + url: '/inputs/all-inputs', + type: DISCARD_INPUTDATA_CHANGES, + onSuccess: data => resolve(data), + onFailure: error => reject(error) + }) + ); + }), + fetchBuildingSchedule(Object.keys(getState().inputData.schedules))( + dispatch, + getState + ) + ]); export const updateInputData = ( table = '', @@ -111,6 +113,22 @@ export const updateInputData = ( payload: { table, buildings, properties } }); +export const updateYearSchedule = (buildings = [], month = '', value = 0) => ({ + type: UPDATE_YEARSCHEDULE, + payload: { buildings, month, value } +}); + +export const updateDaySchedule = ( + buildings = [], + tab = '', + day = '', + hour = 0, + value = '' +) => ({ + type: UPDATE_DAYSCHEDULE, + payload: { buildings, tab, day, hour, value } +}); + export const deleteBuildings = (buildings = []) => ({ type: DELETE_BUILDINGS, payload: { buildings } diff --git a/src/renderer/components/InputEditor/InputEditor.js b/src/renderer/components/InputEditor/InputEditor.js index d3b2a14..f67db0b 100644 --- a/src/renderer/components/InputEditor/InputEditor.js +++ b/src/renderer/components/InputEditor/InputEditor.js @@ -8,7 +8,6 @@ import CenterSpinner from '../HomePage/CenterSpinner'; import NavigationPrompt from './NavigationPrompt'; import { withErrorBoundary } from '../../utils/ErrorBoundary'; import './InputEditor.css'; -import ScheduleEditor from './ScheduleEditor'; const MAP_STYLE = { height: '500px', @@ -78,7 +77,7 @@ const InputTable = () => { {TabPanes}
- {tab != 'schedules' ? : } +
); diff --git a/src/renderer/components/InputEditor/ScheduleEditor.css b/src/renderer/components/InputEditor/ScheduleEditor.css index 4cda7ab..0de72fe 100644 --- a/src/renderer/components/InputEditor/ScheduleEditor.css +++ b/src/renderer/components/InputEditor/ScheduleEditor.css @@ -40,6 +40,14 @@ .cea-schedule-no-data { grid-column: 1/-1; grid-row: 1/-1; + margin: auto; +} + +.cea-schedule-error { + grid-column: 1/-1; + grid-row: 1/-1; + overflow: auto; + text-align: center; height: 100%; width: 100%; } diff --git a/src/renderer/components/InputEditor/ScheduleEditor.js b/src/renderer/components/InputEditor/ScheduleEditor.js index fc97ebc..fb779ce 100644 --- a/src/renderer/components/InputEditor/ScheduleEditor.js +++ b/src/renderer/components/InputEditor/ScheduleEditor.js @@ -1,137 +1,164 @@ import React, { useEffect, useRef, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { fetchBuildingSchedule, setSelected } from '../../actions/inputEditor'; +import interpolate from 'color-interpolate'; +import { + fetchBuildingSchedule, + setSelected, + updateDaySchedule, + updateYearSchedule +} from '../../actions/inputEditor'; import Tabulator from 'tabulator-tables'; import 'tabulator-tables/dist/css/tabulator.min.css'; import './ScheduleEditor.css'; -import CenterSpinner from '../HomePage/CenterSpinner'; import { months_short } from '../../constants/months'; -import { Tabs, Spin, Modal, Button, Card } from 'antd'; +import { Tabs, Spin } from 'antd'; -const ScheduleEditor = () => { - const { selected, tables } = useSelector(state => state.inputData); +const colormap = interpolate(['white', '#006ad5']); + +const ScheduleEditor = ({ selected, schedules, tabulator }) => { + const { tables } = useSelector(state => state.inputData); const [tab, setTab] = useState(null); const [loading, setLoading] = useState(false); const [errors, setErrors] = useState({}); const buildings = Object.keys(tables.zone || {}); const dispatch = useDispatch(); - const tabulator = useRef(null); const divRef = useRef(null); + const timeoutRef = useRef(); const selectRow = (e, cell) => { const row = cell.getRow(); - const selectedRows = cell - .getTable() - .getSelectedData() - .map(data => data.Name); - if (cell.getRow().isSelected()) { - dispatch( - setSelected(selectedRows.filter(name => name !== row.getIndex())) - ); + if (!e.ctrlKey) { + dispatch(setSelected([row.getIndex()])); } else { - dispatch(setSelected([...selectedRows, row.getIndex()])); + const selectedRows = cell + .getTable() + .getSelectedData() + .map(data => data.Name); + if (cell.getRow().isSelected()) { + dispatch( + setSelected(selectedRows.filter(name => name !== row.getIndex())) + ); + } else { + dispatch(setSelected([...selectedRows, row.getIndex()])); + } } }; + // Initialize table useEffect(() => { + const filtered = tabulator.current && tabulator.current.getFilters().length; tabulator.current = new Tabulator(divRef.current, { - data: buildings.map(building => ({ Name: building })), + data: buildings.sort().map(building => ({ Name: building })), index: 'Name', columns: [{ title: 'Name', field: 'Name' }], layout: 'fitColumns', height: '300px', cellClick: selectRow }); - tabulator.current.setSort('Name', 'asc'); + filtered && tabulator.current.setFilter('Name', 'in', selected); }, []); useEffect(() => { + const buildings = Object.keys(tables.zone || {}); + tabulator.current && + tabulator.current.replaceData( + buildings.sort().map(building => ({ Name: building })) + ); + tabulator.current.selectRow(selected); + tabulator.current.redraw(); + }, [tables]); + + useEffect(() => { + tabulator.current && tabulator.current.deselectRow(); if (buildings.includes(selected[0])) { - if (tabulator.current) { - tabulator.current.deselectRow(); - tabulator.current.selectRow(selected); - tabulator.current.getFilters().length && - tabulator.current.setFilter('Name', 'in', selected); - } - if (selected.length) { + tabulator.current && tabulator.current.selectRow(selected); + setLoading(false); + clearTimeout(timeoutRef.current); + const missingSchedules = selected.filter( + building => !Object.keys(schedules).includes(building) + ); + if (missingSchedules.length) { setLoading(true); - dispatch(fetchBuildingSchedule(selected)) - .catch(error => { - console.log(error); - setErrors(error); - }) - .finally(() => { - setLoading(false); - }); + timeoutRef.current = setTimeout(() => { + dispatch(fetchBuildingSchedule(missingSchedules)) + .catch(error => { + console.log(error); + setErrors(error); + }) + .finally(() => { + setLoading(false); + }); + }, 1000); } - } else tabulator.current && tabulator.current.deselectRow(); + } + tabulator.current && + tabulator.current.getFilters().length && + tabulator.current.setFilter('Name', 'in', selected); }, [selected]); if (!buildings.length) return
No buildings found
; return ( - } - > -
-
-
-
- -
- {buildings.includes(selected[0]) ? ( - Object.keys(errors).length ? ( -
- ERRORS FOUND: - {Object.keys(errors).map(building => ( -
{`${building}: ${errors[building].message}`}
- ))} -
- ) : ( - -
- -
-
- -
-
- -
-
- ) - ) : ( -
- {selected.length - ? 'Selected buildings do not have a schedule' - : 'No building selected'} -
- )} -
-
+
+
+
- + +
+ {buildings.includes(selected[0]) ? ( + Object.keys(errors).length ? ( +
+ ERRORS FOUND: + {Object.keys(errors).map(building => ( +
{`${building}: ${errors[building].message}`}
+ ))} +
+ ) : ( + +
+ +
+
+ +
+
+ +
+
+ ) + ) : ( +
+ {selected.length + ? 'Selected buildings do not have a schedule' + : 'No building selected'} +
+ )} +
+
+
); }; -const DataTable = ({ selected, tab }) => { - const { schedules } = useSelector(state => state.inputData); +const DataTable = ({ selected, tab, schedules, loading }) => { const tabulator = useRef(null); const divRef = useRef(null); - - const parseData = (building, tab) => { - const buildingSchedule = schedules[building]; - if (!buildingSchedule || !tab) return []; - const days = buildingSchedule.SCHEDULES[tab]; - return Object.keys(days).map(day => ({ DAY: day, ...days[day] })); - }; + const tooltipsRef = useRef({ selected, schedules, tab }); + const dispatch = useDispatch(); useEffect(() => { tabulator.current = new Tabulator(divRef.current, { @@ -140,42 +167,119 @@ const DataTable = ({ selected, tab }) => { columns: [ { title: 'DAY \\ HOUR', field: 'DAY', width: 100, headerSort: false }, ...[...Array(24).keys()].map(i => ({ - title: i.toString(), + title: (i + 1).toString(), field: i.toString(), - headerSort: false + headerSort: false, + editor: 'input', + // Hack to allow editing when double clicking + cellDblClick: () => {}, + formatter: cell => { + formatCellStyle(cell); + return cell.getValue(); + } })) ], + validationFailed: cell => { + cell.cancelEdit(); + }, + cellEdited: cell => { + formatCellStyle(cell); + dispatch( + updateDaySchedule( + tooltipsRef.current.selected, + tooltipsRef.current.tab, + cell.getData().DAY, + Number(cell.getField()), + cell.getValue() + ) + ); + }, layoutColumnsOnNewData: true, - layout: 'fitDataFill' + layout: 'fitDataFill', + tooltips: cell => { + if (cell.getValue() == 'DIFF') { + let out = ''; + for (const building of tooltipsRef.current.selected.sort()) { + out += `${building} - ${ + tooltipsRef.current.schedules[building].SCHEDULES[ + tooltipsRef.current.tab + ][cell.getData().DAY][cell.getField()] + }\n`; + } + return out; + } + } }); }, []); useEffect(() => { - tabulator.current && tabulator.current.setData(parseData(selected[0], tab)); + tooltipsRef.current = { selected, schedules, tab }; + tab && + selected.every(building => Object.keys(schedules).includes(building)) && + tabulator.current.updateOrAddData(parseData(schedules, selected, tab)); }, [selected, schedules, tab]); + useEffect(() => { + selected.every(building => Object.keys(schedules).includes(building)) && + tabulator.current.redraw(true); + }, [selected, tab, loading]); + return
; }; -const YearTable = ({ selected }) => { - const { schedules } = useSelector(state => state.inputData); +const YearTable = ({ selected, schedules, loading }) => { const tabulator = useRef(null); const divRef = useRef(null); + const tooltipsRef = useRef({ selected, schedules }); + const dispatch = useDispatch(); useEffect(() => { - console.log('render year'); tabulator.current = new Tabulator(divRef.current, { - data: parseYearData(schedules, selected), + data: [], index: 'name', columns: [ { title: '', field: 'name', headerSort: false }, ...[...Array(12).keys()].map(i => ({ title: months_short[i], field: i.toString(), - headerSort: false + headerSort: false, + editor: 'input', + validator: ['max:1', 'min:0'], + // Hack to allow editing when double clicking + cellDblClick: () => {}, + formatter: cell => { + formatCellStyle(cell); + return cell.getValue(); + } })) ], - layout: 'fitDataFill' + validationFailed: cell => { + cell.cancelEdit(); + }, + cellEdited: cell => { + formatCellStyle(cell); + dispatch( + updateYearSchedule( + tooltipsRef.current.selected, + cell.getField(), + cell.getValue() + ) + ); + }, + layout: 'fitDataFill', + tooltips: cell => { + if (cell.getValue() == 'DIFF') { + let out = ''; + for (const building of tooltipsRef.current.selected.sort()) { + out += `${building} - ${ + tooltipsRef.current.schedules[building].MONTHLY_MULTIPLIER[ + cell.getField() + ] + }\n`; + } + return out; + } + } }); const redrawTable = () => { @@ -189,15 +293,20 @@ const YearTable = ({ selected }) => { }, []); useEffect(() => { - tabulator.current && - tabulator.current.setData(parseYearData(schedules, selected)); + tooltipsRef.current = { selected, schedules }; + selected.every(building => Object.keys(schedules).includes(building)) && + tabulator.current.updateOrAddData(parseYearData(schedules, selected)); }, [schedules, selected]); + useEffect(() => { + selected.every(building => Object.keys(schedules).includes(building)) && + tabulator.current.redraw(true); + }, [selected, loading]); + return
; }; -const ScheduleTab = ({ tab, setTab }) => { - const { schedules } = useSelector(state => state.inputData); +const ScheduleTab = ({ tab, setTab, schedules }) => { const TabPanes = getScheduleTypes(schedules).map(schedule => ( )); @@ -214,57 +323,52 @@ const ScheduleTab = ({ tab, setTab }) => { ); }; -const TableButtons = ({ selected, tabulator }) => { - const [filterToggle, setFilterToggle] = useState(false); - const dispatch = useDispatch(); - - const selectAll = () => { - dispatch(setSelected(tabulator.current.getData().map(data => data.Name))); - }; - - const filterSelected = () => { - if (filterToggle) { - tabulator.current.clearFilter(); - } else { - tabulator.current.setFilter('Name', 'in', selected); +const formatCellStyle = cell => { + const states = ['OFF', 'SETBACK', 'SETPOINT']; + const value = cell.getValue(); + if (value == 'DIFF') { + cell.getElement().style.fontWeight = 'bold'; + cell.getElement().style.fontStyle = 'italic'; + } else { + if (!isNaN(value)) { + cell.getElement().style.backgroundColor = addRGBAlpha( + colormap(value), + 0.5 + ); + } else if (states.includes(value)) { + cell.getElement().style.backgroundColor = addRGBAlpha( + colormap(states.indexOf(value) / (states.length - 1)), + 0.5 + ); } - tabulator.current.redraw(); - setFilterToggle(oldValue => !oldValue); - }; - - const clearSelected = () => { - dispatch(setSelected([])); - }; + cell.getElement().style.fontWeight = 'normal'; + cell.getElement().style.fontStyle = 'normal'; + } +}; - return ( -
- - - {selected.length ? ( - - ) : null} -
- ); +const addRGBAlpha = (color, opacity) => { + const rgb = color.replace(/[^\d,]/g, '').split(','); + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${opacity}`; }; const parseYearData = (schedules, selected) => { - if ( - !selected.length || - !selected.every(building => Object.keys(schedules).includes(building)) - ) - return []; + let out = []; for (const building of selected) { const buildingSchedule = schedules[building].MONTHLY_MULTIPLIER; + out = diffArray(out, buildingSchedule); } + return [{ name: 'MONTHLY_MULTIPLIER', ...out }]; +}; - return [ - { name: 'MONTHLY_MULTIPLIER', ...schedules[selected[0]].MONTHLY_MULTIPLIER } - ]; +const parseData = (schedules, selected, tab) => { + let out = {}; + for (const building of selected) { + const days = schedules[building].SCHEDULES[tab]; + for (const day of Object.keys(days)) { + out[day] = diffArray(out[day], days[day]); + } + } + return Object.keys(out).map(day => ({ DAY: day, ...out[day] })); }; const getScheduleTypes = schedules => { @@ -278,4 +382,12 @@ const getScheduleTypes = schedules => { } }; +const diffArray = (first, second) => { + if (typeof first == 'undefined' || !first.length) return second; + return first.map((value, index) => { + if (value == second[index]) return value; + return 'DIFF'; + }); +}; + export default ScheduleEditor; diff --git a/src/renderer/components/InputEditor/Table.js b/src/renderer/components/InputEditor/Table.js index ec919a8..f7100bb 100644 --- a/src/renderer/components/InputEditor/Table.js +++ b/src/renderer/components/InputEditor/Table.js @@ -1,7 +1,8 @@ import React, { useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; import { useSelector, useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; -import { Card, Button, Modal, message } from 'antd'; +import { Card, Button, Modal, message, Tooltip, Icon } from 'antd'; import { setSelected, updateInputData, @@ -13,130 +14,50 @@ import EditSelectedModal from './EditSelectedModal'; import routes from '../../constants/routes'; import Tabulator from 'tabulator-tables'; import 'tabulator-tables/dist/css/tabulator.min.css'; +import ScheduleEditor from './ScheduleEditor'; -const useTableData = tab => { - const tables = useSelector(state => state.inputData.tables); - const columns = useSelector(state => state.inputData.columns); - - const [data, setData] = useState([]); - const [columnDef, setColumnDef] = useState([]); - - const dispatch = useDispatch(); - - const selectRow = (e, cell) => { - const row = cell.getRow(); - const selectedRows = cell - .getTable() - .getSelectedData() - .map(data => data.Name); - if (cell.getRow().isSelected()) { - dispatch( - setSelected(selectedRows.filter(name => name !== row.getIndex())) - ); - } else { - dispatch(setSelected([...selectedRows, row.getIndex()])); - } - }; - - const getData = () => - Object.keys(tables[tab]) - .sort((a, b) => (a > b ? 1 : -1)) - .map(row => ({ - Name: row, - ...tables[tab][row] - })); - - const getColumnDef = () => - Object.keys(columns[tab]).map(column => { - let columnDef = { title: column, field: column }; - if (column === 'Name') { - columnDef.frozen = true; - columnDef.cellClick = selectRow; - } else if (column !== 'REFERENCE') { - columnDef.editor = 'input'; - columnDef.validator = - columns[tab][column].type === 'str' ? 'string' : 'numeric'; - columnDef.minWidth = 100; - // Hack to allow editing when double clicking - columnDef.cellDblClick = () => {}; - } - return columnDef; - }); - - useEffect(() => { - setColumnDef(getColumnDef()); - setData(getData()); - }, [tab]); - - useEffect(() => { - setData(getData()); - }, [tables[tab]]); +const Table = ({ tab }) => { + const { selected, changes, schedules } = useSelector( + state => state.inputData + ); + const tabulator = useRef(null); - return [data, columnDef]; + return ( + + + + + } + extra={ + + } + > + {tab == 'schedules' ? ( + + ) : ( + + )} + + + + ); }; -const Table = ({ tab }) => { - const [data, columnDef] = useTableData(tab); - const { selected, changes } = useSelector(state => state.inputData); +const InputEditorButtons = ({ changes }) => { + const dispatch = useDispatch(); const noChanges = !Object.keys(changes.update).length && !Object.keys(changes.delete).length; - const dispatch = useDispatch(); - const tableRef = useRef(tab); - const tabulator = useRef(null); - const divRef = useRef(null); - - useEffect(() => { - tabulator.current = new Tabulator(divRef.current, { - data: data, - index: 'Name', - columns: columnDef, - layout: 'fitDataFill', - height: '300px', - validationFailed: cell => { - cell.cancelEdit(); - }, - cellEdited: cell => { - dispatch( - updateInputData( - tableRef.current, - [cell.getData()['Name']], - [{ property: cell.getField(), value: cell.getValue() }] - ) - ); - }, - placeholder: '
No matching records found.
' - }); - }, []); - - // Keep reference of current table name - useEffect(() => { - tableRef.current = tab; - }, [tab]); - - useEffect(() => { - if (tabulator.current) { - tabulator.current.setData([]); - tabulator.current.setColumns(columnDef); - } - }, [columnDef]); - - useEffect(() => { - if (tabulator.current) { - if (!tabulator.current.getData().length) { - tabulator.current.setData(data); - tabulator.current.selectRow(selected); - } else tabulator.current.updateData(data); - } - }, [data]); - - useEffect(() => { - if (tabulator.current) { - tabulator.current.deselectRow(); - tabulator.current.selectRow(selected); - tabulator.current.getFilters().length && - tabulator.current.setFilter('Name', 'in', selected); - } - }, [selected]); const _saveChanges = () => { Modal.confirm({ @@ -188,6 +109,9 @@ const Table = ({ tab }) => { await dispatch(discardChanges()) .then(data => { console.log(data); + message.config({ + top: 120 + }); message.info('Unsaved changes have been discarded.'); }) .catch(error => { @@ -200,28 +124,6 @@ const Table = ({ tab }) => { return ( - - } - > -
- {!data.length ? ( -
- Input file could not be found. You can create the file using - {tab == 'surroundings' ? ( - - {' surroundings-helper '} - - ) : ( - {' data-helper '} - )} - tool. -
- ) : null} - @@ -273,7 +175,7 @@ const ChangesSummary = ({ changes }) => { property => (
{property} - {`: ${changes.update[table][building][property].oldValue} + {` : ${changes.update[table][building][property].oldValue} → ${changes.update[table][building][property].newValue}`}
@@ -290,7 +192,7 @@ const ChangesSummary = ({ changes }) => { ); }; -const TableButtons = ({ selected, tabulator, table }) => { +const TableButtons = ({ selected, tabulator, tab }) => { const [filterToggle, setFilterToggle] = useState(false); const [selectedInTable, setSelectedInTable] = useState(true); const [modalVisible, setModalVisible] = useState(false); @@ -298,9 +200,9 @@ const TableButtons = ({ selected, tabulator, table }) => { const dispatch = useDispatch(); useEffect(() => { - const tableData = data[table] || {}; + const tableData = data[tab] || tab == 'schedules' ? data['zone'] : {}; setSelectedInTable(Object.keys(tableData).includes(selected[0])); - }, [table, selected]); + }, [tab, selected]); const selectAll = () => { dispatch(setSelected(tabulator.current.getData().map(data => data.Name))); @@ -312,6 +214,7 @@ const TableButtons = ({ selected, tabulator, table }) => { } else { tabulator.current.setFilter('Name', 'in', selected); } + tabulator.current.redraw(); setFilterToggle(oldValue => !oldValue); }; @@ -357,7 +260,9 @@ const TableButtons = ({ selected, tabulator, table }) => { {selectedInTable ? ( - + {tab != 'schedules' && ( + + )}
); }; +const TableEditor = ({ tab, selected, tabulator }) => { + const [data, columnDef] = useTableData(tab); + const dispatch = useDispatch(); + const divRef = useRef(null); + const tableRef = useRef(tab); + + useEffect(() => { + const filtered = tabulator.current && tabulator.current.getFilters().length; + tabulator.current = new Tabulator(divRef.current, { + data: data, + index: 'Name', + columns: columnDef.columns, + layout: 'fitDataFill', + height: '300px', + validationFailed: cell => { + cell.cancelEdit(); + }, + cellEdited: cell => { + dispatch( + updateInputData( + tableRef.current, + [cell.getData()['Name']], + [{ property: cell.getField(), value: cell.getValue() }] + ) + ); + }, + placeholder: '
No matching records found.
' + }); + filtered && tabulator.current.setFilter('Name', 'in', selected); + }, []); + + // Keep reference of current table name + useEffect(() => { + tableRef.current = tab; + }, [tab]); + + useEffect(() => { + if (tabulator.current) { + tabulator.current.setData([]); + tabulator.current.setColumns(columnDef.columns); + } + }, [columnDef]); + + useEffect(() => { + if (tabulator.current) { + if (!tabulator.current.getData().length) { + tabulator.current.setData(data); + tabulator.current.selectRow(selected); + // Add tooltips to column headers on new data + document + .querySelectorAll('.tabulator-col-content') + .forEach((col, index) => { + const { description, unit } = columnDef.description[index]; + ReactDOM.render( + + {description} +
+ {unit} +
+ ) + } + getPopupContainer={() => { + return document.getElementsByClassName('ant-card-body')[0]; + }} + > +
+ {columnDef.columns[index].title} +
+
+ , + col + ); + }); + } else if (tabulator.current.getData().length == data.length) { + tabulator.current.updateData(data); + } else tabulator.current.setData(data); + } + }, [data]); + + useEffect(() => { + if (tabulator.current) { + tabulator.current.deselectRow(); + tabulator.current.selectRow(selected); + tabulator.current.getFilters().length && + tabulator.current.setFilter('Name', 'in', selected); + } + }, [selected]); + + return ( + +
+ {!data.length ? ( +
+ Input file could not be found. You can create the file using + {tab == 'surroundings' ? ( + + {' surroundings-helper '} + + ) : ( + {' data-helper '} + )} + tool. +
+ ) : null} + + ); +}; + +const useTableData = tab => { + const { columns, tables } = useSelector(state => state.inputData); + const [data, setData] = useState([]); + const [columnDef, setColumnDef] = useState({ columns: [], description: [] }); + + const dispatch = useDispatch(); + + const selectRow = (e, cell) => { + const row = cell.getRow(); + if (!e.ctrlKey) { + dispatch(setSelected([row.getIndex()])); + } else { + const selectedRows = cell + .getTable() + .getSelectedData() + .map(data => data.Name); + if (cell.getRow().isSelected()) { + dispatch( + setSelected(selectedRows.filter(name => name !== row.getIndex())) + ); + } else { + dispatch(setSelected([...selectedRows, row.getIndex()])); + } + } + }; + + const getData = () => + Object.keys(tables[tab]) + .sort() + .map(row => ({ + Name: row, + ...tables[tab][row] + })); + + const getColumnDef = () => { + let description = []; + let _columns = Object.keys(columns[tab]).map(column => { + description.push({ + description: columns[tab][column].description, + unit: columns[tab][column].unit + }); + let columnDef = { title: column, field: column }; + if (column === 'Name') { + columnDef.frozen = true; + columnDef.cellClick = selectRow; + } else if (column !== 'REFERENCE') { + columnDef.editor = 'input'; + columnDef.validator = + columns[tab][column].type === 'str' ? 'string' : 'numeric'; + columnDef.minWidth = 100; + // Hack to allow editing when double clicking + columnDef.cellDblClick = () => {}; + } + return columnDef; + }); + return { columns: _columns, description: description }; + }; + useEffect(() => { + setColumnDef(getColumnDef()); + setData(getData()); + }, [tab]); + + useEffect(() => { + setData(getData()); + }, [tables[tab]]); + + return [data, columnDef]; +}; + export default Table; diff --git a/src/renderer/components/Map/Map.js b/src/renderer/components/Map/Map.js index 61bddac..6277d94 100644 --- a/src/renderer/components/Map/Map.js +++ b/src/renderer/components/Map/Map.js @@ -409,16 +409,19 @@ function updateTooltip({ x, y, object, layer }) { let innerHTML = ''; if (layer.id === 'zone' || layer.id === 'surroundings') { - Object.keys(properties).forEach(key => { - innerHTML += `
${key}: ${properties[key]}
`; - }); - let area = calcArea(object); - innerHTML += - `
area: ${Math.round(area * 1000) / - 1000}m2
` + - `
volume: ${Math.round( - area * properties['height_ag'] * 1000 - ) / 1000}m3
`; + innerHTML += `
Name: ${properties.Name}

`; + Object.keys(properties) + .sort() + .forEach(key => { + if (key != 'Name') + innerHTML += `
${key}: ${properties[key]}
`; + }); + let area = Math.round(calcArea(object) * 1000) / 1000; + innerHTML += `
Floor Area: ${area}m2
`; + if (layer.id === 'zone') + innerHTML += `
GFA: ${Math.round( + (properties['floors_ag'] + properties['floors_bg']) * area * 1000 + ) / 1000}m2
`; } else if (layer.id === 'dc' || layer.id === 'dh') { Object.keys(properties).forEach(key => { if (key !== 'Building' && properties[key] === 'NONE') return null; diff --git a/src/renderer/components/Project/NewProjectModal.js b/src/renderer/components/Project/NewProjectModal.js index 21cc2b1..d151741 100644 --- a/src/renderer/components/Project/NewProjectModal.js +++ b/src/renderer/components/Project/NewProjectModal.js @@ -36,6 +36,7 @@ const NewProjectModal = ({ setVisible(false); } catch (err) { console.log(err.response); + } finally { setConfirmLoading(false); } } diff --git a/src/renderer/components/Project/OpenProjectModal.js b/src/renderer/components/Project/OpenProjectModal.js index 7db4dfc..b7c56fd 100644 --- a/src/renderer/components/Project/OpenProjectModal.js +++ b/src/renderer/components/Project/OpenProjectModal.js @@ -27,6 +27,7 @@ const OpenProjectModal = ({ setVisible(false); } catch (err) { console.log(err.response); + } finally { setConfirmLoading(false); } } diff --git a/src/renderer/reducers/inputEditor.js b/src/renderer/reducers/inputEditor.js index 3981f7c..05cd0af 100644 --- a/src/renderer/reducers/inputEditor.js +++ b/src/renderer/reducers/inputEditor.js @@ -9,10 +9,13 @@ import { RESET_INPUTDATA, SET_SELECTED, UPDATE_INPUTDATA, + UPDATE_YEARSCHEDULE, + UPDATE_DAYSCHEDULE, DELETE_BUILDINGS, SAVE_INPUTDATA_SUCCESS, DISCARD_INPUTDATA_CHANGES_SUCCESS } from '../actions/inputEditor'; +import { months_short } from '../constants/months'; const initialState = { selected: [], @@ -32,32 +35,48 @@ function createNestedProp(obj, prop, ...rest) { return createNestedProp(obj[prop], ...rest); } +function updateChanges( + changes, + table, + building, + property, + storedValue, + newValue +) { + if (createNestedProp(changes.update, table, building, property, 'oldValue')) { + // Delete update if newValue equals oldValue else update newValue + if (changes.update[table][building][property].oldValue == newValue) { + delete changes.update[table][building][property]; + // Delete update building entry if it is empty + if (!Object.keys(changes.update[table][building]).length) { + delete changes.update[table][building]; + if (!Object.keys(changes.update[table]).length) + delete changes.update[table]; + } + } else changes.update[table][building][property].newValue = newValue; + } else { + // Store old and new value + changes.update[table][building][property] = { + oldValue: storedValue, + newValue: newValue + }; + } +} + function updateData(state, table, buildings, properties) { let { geojsons, tables, changes } = state; for (const building of buildings) { - for (const _property of properties) { - const { property, value } = _property; + for (const propertyObj of properties) { + const { property, value } = propertyObj; // Track update changes - if ( - createNestedProp(changes.update, table, building, property, 'oldValue') - ) { - // Delete update if newValue equals oldValue else update newValue - if (changes.update[table][building][property].oldValue == value) { - delete changes.update[table][building][property]; - // Delete update building entry if it is empty - if (!Object.keys(changes.update[table][building]).length) { - delete changes.update[table][building]; - if (!Object.keys(changes.update[table]).length) - delete changes.update[table]; - } - } else changes.update[table][building][property].newValue = value; - } else { - // Store old and new value - changes.update[table][building][property] = { - oldValue: tables[table][building][property], - newValue: value - }; - } + updateChanges( + changes, + table, + building, + property, + tables[table][building][property], + value + ); tables = { ...tables, @@ -75,7 +94,12 @@ function updateData(state, table, buildings, properties) { // Update building properties of geojsons if (buildingGeometries.includes(table)) { - geojsons = updateGeoJsonProperty(geojsons, table, building, _property); + geojsons = updateGeoJsonProperty( + geojsons, + table, + building, + propertyObj + ); } } } @@ -145,6 +169,64 @@ function deleteGeoJsonFeature(geojsons, table, building) { }; } +function updateDaySchedule(state, buildings, tab, day, hour, value) { + let { schedules, changes } = state; + for (const building of buildings) { + // Track update changes + updateChanges( + changes, + 'schedules', + building, + `${tab}_${day}_${hour + 1}`, + schedules[building].SCHEDULES[tab][day][hour], + value + ); + + let daySchedule = schedules[building].SCHEDULES[tab][day]; + daySchedule[hour] = value; + schedules = { + ...schedules, + [building]: { + ...schedules[building], + SCHEDULES: { + ...schedules[building].SCHEDULES, + [tab]: { + ...schedules[building].SCHEDULES[tab], + [day]: daySchedule + } + } + } + }; + } + return { schedules }; +} + +function updateYearSchedule(state, buildings, month, value) { + let { schedules, changes } = state; + for (const building of buildings) { + // Track update changes + updateChanges( + changes, + 'schedules', + building, + `MONTHLY_MULTIPLIER_${months_short[month]}`, + schedules[building].MONTHLY_MULTIPLIER[month], + value + ); + + let monthSchedule = schedules[building].MONTHLY_MULTIPLIER; + monthSchedule[month] = value; + schedules = { + ...schedules, + [building]: { + ...schedules[building], + MONTHLY_MULTIPLIER: monthSchedule + } + }; + } + return { schedules }; +} + const inputData = (state = initialState, { type, payload }) => { switch (type) { case REQUEST_INPUTDATA: @@ -165,6 +247,28 @@ const inputData = (state = initialState, { type, payload }) => { payload.properties ) }; + case UPDATE_YEARSCHEDULE: + return { + ...state, + ...updateYearSchedule( + state, + payload.buildings, + payload.month, + payload.value + ) + }; + case UPDATE_DAYSCHEDULE: + return { + ...state, + ...updateDaySchedule( + state, + payload.buildings, + payload.tab, + payload.day, + payload.hour, + payload.value + ) + }; case DELETE_BUILDINGS: return { ...state, diff --git a/yarn.lock b/yarn.lock index 62564bd..c0c3f10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2695,6 +2695,11 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +almost-equal@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/almost-equal/-/almost-equal-1.1.0.tgz#f851c631138757994276aa2efbe8dfa3066cccdd" + integrity sha1-+FHGMROHV5lCdqou++jfowZszN0= + alphanum-sort@^1.0.1, alphanum-sort@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -3623,6 +3628,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" +clamp@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/clamp/-/clamp-1.0.1.tgz#66a0e64011816e37196828fdc8c8c147312c8634" + integrity sha1-ZqDmQBGBbjcZaCj9yMjBRzEshjQ= + clap@^1.0.9: version "1.2.3" resolved "https://registry.yarnpkg.com/clap/-/clap-1.2.3.tgz#4f36745b32008492557f46412d66d50cb99bce51" @@ -3740,6 +3750,16 @@ color-convert@^1.3.0, color-convert@^1.9.0: dependencies: color-name "1.1.3" +color-interpolate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/color-interpolate/-/color-interpolate-1.0.5.tgz#d5710ce4244bd8b9feeda003f409edd4073b6217" + integrity sha512-EcWwYtBJdbeHyYq/y5QwVWLBUm4s7+8K37ycgO9OdY6YuAEa0ywAY+ItlAxE1UO5bXW4ugxNhStTV3rsTZ35ZA== + dependencies: + clamp "^1.0.1" + color-parse "^1.2.0" + color-space "^1.14.3" + lerp "^1.0.3" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" @@ -3750,6 +3770,23 @@ color-name@^1.0.0: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-parse@^1.2.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/color-parse/-/color-parse-1.3.8.tgz#eaf54cd385cb34c0681f18c218aca38478082fa3" + integrity sha512-1Y79qFv0n1xair3lNMTNeoFvmc3nirMVBij24zbs1f13+7fPpQClMg5b4AuKXLt3szj7BRlHMCXHplkce6XlmA== + dependencies: + color-name "^1.0.0" + defined "^1.0.0" + is-plain-obj "^1.1.0" + +color-space@^1.14.3: + version "1.16.0" + resolved "https://registry.yarnpkg.com/color-space/-/color-space-1.16.0.tgz#611781bca41cd8582a1466fd9e28a7d3d89772a2" + integrity sha512-A6WMiFzunQ8KEPFmj02OnnoUnqhmSaHaZ/0LVFcPTdlvm8+3aMJ5x1HRHy3bDHPkovkf4sS0f4wsVvwk71fKkg== + dependencies: + hsluv "^0.0.3" + mumath "^3.3.4" + color-string@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" @@ -6153,6 +6190,11 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" +hsluv@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/hsluv/-/hsluv-0.0.3.tgz#829107dafb4a9f8b52a1809ed02e091eade6754c" + integrity sha1-gpEH2vtKn4tSoYCe0C4JHq3mdUw= + html-comment-regex@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" @@ -6734,7 +6776,7 @@ is-path-inside@^2.1.0: dependencies: path-is-inside "^1.0.2" -is-plain-obj@^1.0.0: +is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= @@ -7049,6 +7091,11 @@ lcid@^2.0.0: dependencies: invert-kv "^2.0.0" +lerp@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/lerp/-/lerp-1.0.3.tgz#a18c8968f917896de15ccfcc28d55a6b731e776e" + integrity sha1-oYyJaPkXiW3hXM/MKNVaa3Med24= + levn@^0.3.0, levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" @@ -7653,6 +7700,13 @@ multicast-dns@^6.0.1: dns-packet "^1.3.1" thunky "^1.0.2" +mumath@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/mumath/-/mumath-3.3.4.tgz#48d4a0f0fd8cad4e7b32096ee89b161a63d30bbf" + integrity sha1-SNSg8P2MrU57Mglu6JsWGmPTC78= + dependencies: + almost-equal "^1.1.0" + murmurhash-js@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51"