Skip to content

Commit

Permalink
Merge branch 'OpenEnergyDashboard:development' into development
Browse files Browse the repository at this point in the history
  • Loading branch information
BrendenHaskins authored Nov 25, 2024
2 parents 391a0e7 + c12f390 commit 93f7883
Show file tree
Hide file tree
Showing 8 changed files with 1,035 additions and 11 deletions.
693 changes: 685 additions & 8 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"bootstrap": "~5.3.3",
"csv": "~5.3.2",
"csv-stringify": "~5.6.5",
"d3": "~7.8.5",
"dotenv": "~16.4.5",
"escape-html": "~1.0.3",
"express": "~4.19.2",
Expand Down Expand Up @@ -104,6 +105,7 @@
},
"devDependencies": {
"@redux-devtools/extension": "~3.2.5",
"@types/d3": "~7.4.3",
"@types/lodash": "~4.17.4",
"@types/node": "~20.14.10",
"@types/plotly.js": "~2.29.2",
Expand Down
11 changes: 10 additions & 1 deletion src/client/app/components/HeaderButtonsComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default function HeaderButtonsComponent() {
shouldCSVReadingsButtonDisabled: true,
shouldUnitsButtonDisabled: true,
shouldConversionsButtonDisabled: true,
shouldVisualUnitMapButtonDisabled: true,
// Translated menu title that depend on whether logged in.
menuTitle: '',
// link to help page for page choices. Should not see default but use general help URL.
Expand Down Expand Up @@ -99,7 +100,8 @@ export default function HeaderButtonsComponent() {
shouldCSVMetersButtonDisabled: pathname === '/csvMeters',
shouldCSVReadingsButtonDisabled: pathname === '/csvReadings',
shouldUnitsButtonDisabled: pathname === '/units',
shouldConversionsButtonDisabled: pathname === '/conversions'
shouldConversionsButtonDisabled: pathname === '/conversions',
shouldVisualUnitMapButtonDisabled: pathname === '/visual-unit'
}));
}, [pathname]);

Expand Down Expand Up @@ -227,6 +229,13 @@ export default function HeaderButtonsComponent() {
to="/units">
<FormattedMessage id='units' />
</DropdownItem>
<DropdownItem
style={state.adminViewableLinkStyle}
disabled={state.shouldVisualUnitMapButtonDisabled}
tag={Link}
to="/visual-unit">
<FormattedMessage id='visual.unit' />
</DropdownItem>
<DropdownItem divider style={state.adminViewableLinkStyle} />
<DropdownItem
style={state.adminViewableLinkStyle}
Expand Down
4 changes: 3 additions & 1 deletion src/client/app/components/RouteComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import RoleOutlet from './router/RoleOutlet';
import UnitsDetailComponent from './unit/UnitsDetailComponent';
import ErrorComponent from './router/ErrorComponent';
import { selectSelectedLanguage } from '../redux/slices/appStateSlice';
import VisualUnitDetailComponent from './visual-unit/VisualUnitDetailComponent';

/**
* @returns the router component Responsible for client side routing.
Expand Down Expand Up @@ -58,7 +59,8 @@ const router = createBrowserRouter([
{ path: 'csvMeters', element: <MetersCSVUploadComponent /> },
{ path: 'maps', element: <MapsDetailContainer /> },
{ path: 'units', element: <UnitsDetailComponent /> },
{ path: 'users', element: <UsersDetailComponent /> }
{ path: 'users', element: <UsersDetailComponent /> },
{ path: 'visual-unit', element: <VisualUnitDetailComponent/> }
]
},
{
Expand Down
3 changes: 2 additions & 1 deletion src/client/app/components/TooltipHelpComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export default function TooltipHelpComponent(props: TooltipHelpProps) {
'help.home.toggle.chart.link': { link: `${helpUrl}/chartLink/` },
'help.groups.groupdetails': { link: `${helpUrl}/groupViewing/#groupDetails` },
'help.groups.groupview': { link: `${helpUrl}/groupViewing/` },
'help.meters.meterview': { link: `${helpUrl}/meterViewing/` }
'help.meters.meterview': { link: `${helpUrl}/meterViewing/` },
'help.admin.unitconversionvisuals': { link: `${helpUrl}/adminUnitVisual/` }
};

return (
Expand Down
240 changes: 240 additions & 0 deletions src/client/app/components/visual-unit/CreateVisualUnitComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import * as React from 'react';
import * as d3 from 'd3';
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useAppSelector } from '../../redux/reduxHooks';
import { ConversionData } from 'types/redux/conversions';
import { CikData } from 'types/redux/ciks';
import { selectAllUnits } from '../../redux/api/unitsApi';

/**
* Visual graph component that shows the relationship between units and conversions
* entered by an admin.
* @returns D3 force graph visual
*/
interface CreateVisualUnitProps {
conversions: ConversionData[] | CikData[];
isCik?: boolean;
}

export const CreateVisualUnitComponent: React.FC<CreateVisualUnitProps> = ({
conversions,
isCik = false
}) => {
const intl = useIntl();
/* Get unit data from redux */
const units = useAppSelector(selectAllUnits);

/* creating color schema for nodes based on their unit type */
const colors = ['#1F77B4', '#2CA02C', '#fd7e14', '#e377c2'];
const colorSchema = d3.scaleOrdinal<string, string>()
.domain(['meter', 'unit', 'suffix', 'suffix.input'])
.range(colors);

/* Create data container to pass to D3 force graph */
const data: { nodes: any[], links: any[] } = {
nodes: [],
links: []
};

units.map(value =>
data.nodes.push({
'name': value.name,
'id': value.id,
'typeOfUnit': value.typeOfUnit,
'suffix': value.suffix
})
);

conversions.map(value => {
if (isCik) {
const cikValue = value as CikData;
data.links.push({
'source': cikValue.meterUnitId,
'target': cikValue.nonMeterUnitId,
'bidirectional': false
});
} else {
const conversionValue = value as ConversionData;
data.links.push({
'source': conversionValue.sourceId,
'target': conversionValue.destinationId,
/* boolean value */
'bidirectional': conversionValue.bidirectional
});
}
});

/* Visuals start here */
useEffect(() => {
/* View-box dimensions */
const width = window.innerWidth;
const height = isCik ? 1000 : 750;

/* Grab data */
const nodes = data.nodes.map(d => ({...d}));
const links = data.links.map(d => ({...d}));

const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links)
/* Set all link ids (from data.links) */
.id((d: any) => d.id)
/* This controls how long each link is */
.distance(isCik ? 120 : 90)
)
/* Create new many-body force */
.force('charge', d3.forceManyBody()
/* This controls the 'repelling' force on each node */
.strength(isCik ? -800 : -500)
)
.force('x', d3.forceX())
.force('y', d3.forceY());

const svg = d3.select(isCik ? '#sample-cik' : '#sample')
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [-width / 2, -height / 2, width, height])
.attr('style', 'max-width: 100%; height: auto;')
.append('g');

/* End arrow head */
svg.append('defs').append('marker')
.attr('id', 'arrow-end')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('markerWidth', 4)
.attr('markerHeight', 4)
/* auto: point towards dest. node */
.attr('orient', 'auto')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5');

if (!isCik) {
/* Start arrow head (for bidirectional edges) */
svg.append('defs').append('marker')
.attr('id', 'arrow-start')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('markerWidth', 4)
.attr('markerHeight', 4)
/* auto-start-reverse: point towards src. node */
.attr('orient', 'auto-start-reverse')
.append('svg:path')
.attr('d', 'M0,-5L10,0L0,5');
}

/* Link style */
const link = svg.selectAll('line')
.data(links)
.enter().append('line')
.style('stroke', '#aaa')
.attr('stroke-width', 3)
.attr('marker-end', 'url(#arrow-end)')
/* Only draw start arrow head if bidirectional */
.attr('marker-start', d => d.bidirectional === true ? 'url(#arrow-start)' : '');

/* Node style */
const node = svg.selectAll('.node')
.data(nodes)
.enter().append('circle')
/* Node radius */
.attr('r', 20)
/* checks if unit has a non empty suffix to color differently */
.attr('fill', d => d.suffix && d.typeOfUnit === 'unit' ? colorSchema('suffix.input') : colorSchema(d.typeOfUnit));

/* Drag behavior */
node.call(d3.drag()
.on('start', dragstart)
.on('drag', dragged)
.on('end', dragend));

/* Node label style */
const label = svg.selectAll('.label')
.data(nodes)
.enter()
.append('text')
.text(function (d) { return d.name; })
.style('text-anchor', 'middle')
.style('fill', '#000')
.style('font-family', 'Arial')
.style('font-size', 14);

/* Update element positions when moved */
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);

node
.attr('cx', d => d.x)
.attr('cy', d => d.y);

label
.attr('x', function(d){ return d.x; })
.attr('y', function (d) {return d.y - 25; });
});

// eslint-disable-next-line jsdoc/require-jsdoc
function dragstart(event: any) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}

// eslint-disable-next-line jsdoc/require-jsdoc
function dragged(event: any) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}

// eslint-disable-next-line jsdoc/require-jsdoc
function dragend(event: any) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}

/* Color Legend */
const legend = svg.append('g')
.attr('transform', `translate(${-width / 2 + 20}, ${-height / 2 + 20})`);

colorSchema.domain().forEach((item, i) => {
const legendEntry = legend.append('g')
.attr('transform', `translate(0, ${i * 30})`);

// Rectangle color box
legendEntry.append('circle')
.attr('r', 15)
.attr('cx', 15) // Center the circle horizontally
.attr('cy', 15) // Center the circle vertically
.attr('fill', colorSchema(item));

// Text label
legendEntry.append('text')
.attr('x', 40) // Position the text to the right of the circle
.attr('y', 20) // Align the text vertically with the circle
.style('fill', '#000')
.style('font-size', '14px')
.style('alignment-middle', 'middle')
/* internationalizing color legend text */
.text(intl.formatMessage({id : `legend.graph.text.${item}`}));
});

// Empty dependency array to run the effect only once
}, []);

return(
<div>
<div id={isCik ? 'sample-cik' : 'sample'}></div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { FormattedMessage } from 'react-intl';
import TooltipHelpComponent from '../TooltipHelpComponent';
import * as React from 'react';
import { CreateVisualUnitComponent } from './CreateVisualUnitComponent';
import TooltipMarkerComponent from '../TooltipMarkerComponent';
import { selectCik } from '../../redux/api/conversionsApi';
import { selectConversionsDetails } from '../../redux/api/conversionsApi';
import { useAppSelector } from '../../redux/reduxHooks';

/**
* Defines the units and conversion graphics view.
* @returns Units visual graphics page element
*/
export default function VisualUnitDetailComponent() {
/* Get conversion data from redux */
const conversionData = useAppSelector(selectConversionsDetails);
const cikData = useAppSelector(selectCik);



const titleStyle: React.CSSProperties = {
textAlign: 'center'
};

const tooltipStyle = {
display: 'inline-block',
fontSize: '50%',
// For now, only an admin can see the unit visualization page.
tooltipVisualUnitView: 'help.admin.unitconversionvisuals'
};

return (
<div>
<TooltipHelpComponent page='visual-unit' />

<div className='container-fluid'>
<h1 style={titleStyle}>
<FormattedMessage id='units.conversion.page.title' />
<div style={tooltipStyle}>
<TooltipMarkerComponent page='visual-unit' helpTextId={tooltipStyle.tooltipVisualUnitView} />
</div>
</h1>

<h2 style={titleStyle}>
<FormattedMessage id='visual.input.units.graphic' />
</h2>

<div style={titleStyle}>
<CreateVisualUnitComponent conversions={conversionData}/>
</div>

<h2 style={titleStyle}>
<FormattedMessage id='visual.analyzed.units.graphic' />
</h2>

<div style={titleStyle}>
<CreateVisualUnitComponent conversions={cikData} isCik/>
</div>
</div>
</div>
);
}
Loading

0 comments on commit 93f7883

Please sign in to comment.