New List, Map, and Settings components (#86)

This commit is contained in:
Maciej Hirsz
2018-10-12 17:34:49 +02:00
committed by GitHub
parent 2ed9061d23
commit 97b3d1ec23
20 changed files with 405 additions and 289 deletions
@@ -32,53 +32,3 @@
color: #fff;
box-shadow: rgba(0,0,0,0.5) 0 3px 30px;
}
.Chain-map {
min-width: 1350px;
background: url('../../assets/world-map.svg') no-repeat;
background-size: contain;
background-position: center;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.Chain-no-nodes {
font-size: 30px;
padding-top: 20vh;
text-align: center;
font-weight: 300;
}
.Chain-node-list {
width: 100%;
border-spacing: 0;
}
.Chain-node-list thead {
background: #3c3c3b;
}
.Chain-node-list tbody {
font-family: monospace, sans-serif;
}
.Chain-settings {
text-align: center;
}
.Chain-settings-category {
text-align: left;
width: 500px;
margin: 0 auto;
padding: 2em 0;
}
.Chain-settings-category h2 {
padding: 0;
margin: 0 0 0.5em 0;
font-size: 20px;
font-weight: 100;
}
+18 -161
View File
@@ -1,9 +1,9 @@
import * as React from 'react';
import { Types, Maybe } from '@dotstats/common';
import { State as AppState, Node as NodeState } from '../../state';
import { formatNumber, secondsWithPrecision, viewport, getHashData } from '../../utils';
import { formatNumber, secondsWithPrecision, getHashData } from '../../utils';
import { Tab, Filter } from './';
import { Tile, Node, Ago, Setting } from '../';
import { Tile, Ago, List, Map, Settings } from '../';
import { PersistentObject, PersistentSet } from '../../persist';
import blockIcon from '../../icons/package.svg';
@@ -13,12 +13,6 @@ import listIcon from '../../icons/list-alt-regular.svg';
import worldIcon from '../../icons/location.svg';
import settingsIcon from '../../icons/settings.svg';
const MAP_RATIO = 800 / 350;
const MAP_HEIGHT_ADJUST = 400 / 350;
const HEADER = 148;
const TH_HEIGHT = 35;
const TR_HEIGHT = 31;
const ESCAPE_KEY = 27;
import './Chain.css';
@@ -35,12 +29,6 @@ export namespace Chain {
export interface State {
display: Display;
filter: Maybe<string>;
map: {
width: number;
height: number;
top: number;
left: number;
}
}
}
@@ -62,30 +50,21 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
this.state = {
display,
filter: null,
map: {
width: 0,
height: 0,
top: 0,
left: 0
}
};
}
public componentWillMount() {
this.onResize();
window.addEventListener('resize', this.onResize);
window.addEventListener('keyup', this.onKeyUp);
}
public componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
window.removeEventListener('keyup', this.onKeyUp);
}
public render() {
const { best, blockTimestamp, blockAverage } = this.props.appState;
const currentTab = this.state.display;
const { appState } = this.props;
const { best, blockTimestamp, blockAverage } = appState;
const { display: currentTab } = this.state;
return (
<div className="Chain">
@@ -101,159 +80,37 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
</div>
<div className="Chain-content-container">
<div className="Chain-content">
{
currentTab === 'list'
? this.renderList()
: currentTab === 'map'
? this.renderMap()
: this.renderSettings()
}
{this.renderContent()}
</div>
</div>
</div>
);
}
private setDisplay = (display: Chain.Display) => {
this.setState({ display });
};
private renderContent() {
const { display, filter } = this.state;
private renderList() {
const { settings } = this.props.appState;
const { pins } = this.props;
const { filter } = this.state;
const nodeFilter = this.getNodeFilter();
const nodes = nodeFilter ? this.nodes().filter(nodeFilter) : this.nodes();
const columns = Node.Row.columns.filter(({ setting }) => setting == null || settings[setting]);
if (nodeFilter && nodes.length === 0) {
return (
<React.Fragment>
<Filter value={filter} onChange={this.onFilterChange} />
<div className="Chain-no-nodes">¯\_()_/¯<br />Nothing matches</div>
</React.Fragment>
);
if (display === 'settings') {
return <Settings settings={this.props.settings} />;
}
const height = TH_HEIGHT + nodes.length * TR_HEIGHT;
return (
<div style={{ height }}>
<Filter value={filter} onChange={this.onFilterChange} />
<table className="Chain-node-list">
<Node.Row.Header columns={columns} />
<tbody>
{
nodes.map((node) => <Node.Row key={node.id} node={node} pins={pins} columns={columns} />)
}
</tbody>
</table>
</div>
);
}
private renderMap() {
const { filter } = this.state;
const nodeFilter = this.getNodeFilter();
const { appState, pins } = this.props;
return (
<React.Fragment>
{filter != null ? <Filter value={filter} onChange={this.onFilterChange} /> : null}
<div className="Chain-map">
<Filter value={filter} onChange={this.onFilterChange} />
{
this.nodes().map((node) => {
const { lat, lon } = node;
const focused = nodeFilter == null || nodeFilter(node);
if (lat == null || lon == null) {
// Skip nodes with unknown location
return null;
}
const position = this.pixelPosition(lat, lon);
return (
<Node.Location key={node.id} position={position} focused={focused} node={node} />
);
})
display === 'list'
? <List filter={this.getNodeFilter()} appState={appState} pins={pins} />
: <Map filter={this.getNodeFilter()} appState={appState} />
}
</div>
</React.Fragment>
);
}
private renderSettings() {
const { settings } = this.props;
return (
<div className="Chain-settings">
<div className="Chain-settings-category">
<h2>Visible Columns</h2>
{
Node.Row.columns
.map(({ label, icon, setting }, index) => {
if (!setting) {
return null;
}
return <Setting key={index} setting={setting} settings={settings} icon={icon} label={label} />;
})
}
</div>
</div>
);
}
private nodes(): NodeState[] {
return this.props.appState.nodes.sorted();
}
private pixelPosition(lat: Types.Latitude, lon: Types.Longitude): Node.Location.Position {
const { map } = this.state;
// Longitude ranges -180 (west) to +180 (east)
// Latitude ranges +90 (north) to -90 (south)
const left = Math.round(((180 + lon) / 360) * map.width + map.left);
const top = Math.round(((90 - lat) / 180) * map.height + map.top) * MAP_HEIGHT_ADJUST;
let quarter: Node.Location.Quarter = 0;
if (lon > 0) {
quarter = (quarter | 1) as Node.Location.Quarter;
}
if (lat < 0) {
quarter = (quarter | 2) as Node.Location.Quarter;
}
return { left, top, quarter };
}
private onResize: () => void = () => {
const vp = viewport();
vp.width = Math.max(1350, vp.width);
vp.height -= HEADER;
const ratio = vp.width / vp.height;
let top = 0;
let left = 0;
let width = 0;
let height = 0;
if (ratio >= MAP_RATIO) {
width = Math.round(vp.height * MAP_RATIO);
height = Math.round(vp.height);
left = (vp.width - width) / 2;
} else {
width = Math.round(vp.width);
height = Math.round(vp.width / MAP_RATIO);
top = (vp.height - height) / 2;
}
this.setState({ map: { top, left, width, height }});
}
private setDisplay = (display: Chain.Display) => {
this.setState({ display });
};
private onKeyUp = (event: KeyboardEvent) => {
if (event.ctrlKey) {
@@ -0,0 +1,66 @@
.Chain-header {
width: 100%;
height: 108px;
overflow: hidden;
background: #fff;
color: #000;
min-width: 1350px;
position: relative;
}
.Chain-tabs {
position: absolute;
right: 5px;
bottom: 10px;
width: 200px;
text-align: right;
}
.Chain-content-container {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 148px;
}
.Chain-content {
width: 100%;
min-width: 1350px;
min-height: 100%;
background: #222;
color: #fff;
box-shadow: rgba(0,0,0,0.5) 0 3px 30px;
}
.Chain-map {
min-width: 1350px;
background: url('../../assets/world-map.svg') no-repeat;
background-size: contain;
background-position: center;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.List-no-nodes {
font-size: 30px;
padding-top: 20vh;
text-align: center;
font-weight: 300;
}
.List table {
width: 100%;
border-spacing: 0;
}
.List thead {
background: #3c3c3b;
}
.List tbody {
font-family: monospace, sans-serif;
}
@@ -0,0 +1,54 @@
import * as React from 'react';
import { Types, Maybe } from '@dotstats/common';
import { State as AppState, Node } from '../../state';
import { Row } from './';
import { PersistentSet } from '../../persist';
// const HEADER = 148;
const TH_HEIGHT = 35;
const TR_HEIGHT = 31;
import './List.css';
export namespace List {
export interface Props {
filter: Maybe<(node: Node) => boolean>;
appState: Readonly<AppState>;
pins: PersistentSet<Types.NodeName>;
}
}
export class List extends React.Component<List.Props, {}> {
public render() {
const { settings } = this.props.appState;
const { pins, filter } = this.props;
const columns = Row.columns.filter(({ setting }) => setting == null || settings[setting]);
let nodes = this.props.appState.nodes.sorted();
if (filter != null) {
nodes = nodes.filter(filter);
if (nodes.length === 0) {
return (
<div className="List List-no-nodes">¯\_()_/¯<br />Nothing matches</div>
);
}
}
const height = TH_HEIGHT + nodes.length * TR_HEIGHT;
return (
<div className="List" style={{ height }}>
<table>
<Row.Header columns={columns} />
<tbody>
{
nodes.map((node) => <Row key={node.id} node={node} pins={pins} columns={columns} />)
}
</tbody>
</table>
</div>
);
}
}
@@ -1,23 +1,23 @@
.Node-Row {
.Row {
color: #999;
cursor: pointer;
}
.Node-Row-Header th, .Node-Row td {
.Row-Header th, .Row td {
text-align: left;
padding: 6px 13px;
height: 19px;
}
.Node-Row td {
.Row td {
position: relative;
}
.Node-Row-Header th {
.Row-Header th {
height: 23px;
}
.Node-Row .Node-Row-truncate {
.Row .Row-truncate {
position: absolute;
left: 0;
right: 0;
@@ -28,40 +28,40 @@
text-overflow: ellipsis;
}
.Node-Row .Node-Row-Tooltip {
.Row .Row-Tooltip {
position: initial;
padding: inherit;
}
.Node-Row-synced {
.Row-synced {
color: #fff;
}
.Node-Row-pinned td:first-child {
.Row-pinned td:first-child {
border-left: 3px solid #d64ca8;
padding-left: 10px;
}
.Node-Row-pinned td:last-child {
.Row-pinned td:last-child {
border-right: 3px solid #d64ca8;
padding-right: 10px;
}
.Node-Row-pinned.Node-Row-synced {
.Row-pinned.Row-synced {
color: #d64ca8;
}
.Node-Row:hover {
.Row:hover {
background-color: #161616;
}
.Node-Row-validator {
.Row-validator {
display: block;
width: 16px;
height: 16px;
cursor: pointer;
}
.Node-Row-validator:hover {
.Row-validator:hover {
transform: scale(2);
}
@@ -4,7 +4,7 @@ import { Types, Maybe, timestamp } from '@dotstats/common';
import { formatNumber, milliOrSecond, secondsWithPrecision } from '../../utils';
import { State as AppState, Node } from '../../state';
import { PersistentSet } from '../../persist';
import { SEMVER_PATTERN, Truncate } from './';
import { Truncate } from './';
import { Ago, Icon, Tooltip, Sparkline } from '../';
import nodeIcon from '../../icons/server.svg';
@@ -27,19 +27,23 @@ import unknownImplementationIcon from '../../icons/question-solid.svg';
import './Row.css';
interface RowProps {
node: Node;
pins: PersistentSet<Types.NodeName>;
columns: Column[];
};
const SEMVER_PATTERN = /^\d+\.\d+\.\d+/;
interface RowState {
update: number;
};
export namespace Row {
export interface Props {
node: Node;
pins: PersistentSet<Types.NodeName>;
columns: Column[];
}
export interface State {
update: number;
}
}
interface HeaderProps {
columns: Column[];
};
}
interface Column {
label: string;
@@ -82,7 +86,7 @@ function formatCPU(cpu: number, stamp: Maybe<Types.Timestamp>): string {
return `${cpu.toFixed(fractionDigits)}%${ago}`;
}
export default class Row extends React.Component<RowProps, RowState> {
export class Row extends React.Component<Row.Props, Row.State> {
public static readonly columns: Column[] = [
{
label: 'Node',
@@ -95,7 +99,7 @@ export default class Row extends React.Component<RowProps, RowState> {
width: 16,
setting: 'validator',
render: ({ validator }) => {
return validator ? <Tooltip text={validator} copy={true}><span className="Node-Row-validator"><Identicon id={validator} size={16} /></span></Tooltip> : '-';
return validator ? <Tooltip text={validator} copy={true}><span className="Row-validator"><Identicon id={validator} size={16} /></span></Tooltip> : '-';
}
},
{
@@ -210,7 +214,7 @@ export default class Row extends React.Component<RowProps, RowState> {
return (
<thead>
<tr className="Node-Row-Header">
<tr className="Row-Header">
{
columns.map(({ icon, width, label }, index) => {
const position = index === 0 ? 'left'
@@ -243,21 +247,21 @@ export default class Row extends React.Component<RowProps, RowState> {
node.unsubscribe(this.onUpdate);
}
public shouldComponentUpdate(nextProps: RowProps, nextState: RowState): boolean {
public shouldComponentUpdate(nextProps: Row.Props, nextState: Row.State): boolean {
return this.props.node.id !== nextProps.node.id || this.state.update !== nextState.update;
}
public render() {
const { node, columns } = this.props;
let className = 'Node-Row';
let className = 'Row';
if (node.propagationTime != null) {
className += ' Node-Row-synced';
className += ' Row-synced';
}
if (node.pinned) {
className += ' Node-Row-pinned';
className += ' Row-pinned';
}
return (
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Tooltip } from '../';
namespace Truncate {
export namespace Truncate {
export interface Props {
text: string;
copy?: boolean;
@@ -9,13 +9,13 @@ namespace Truncate {
}
}
class Truncate extends React.Component<Truncate.Props, {}> {
export class Truncate extends React.Component<Truncate.Props, {}> {
public render() {
const { text, position, copy } = this.props;
return (
<Tooltip text={text} position={position} copy={copy} className="Node-Row-Tooltip">
<div className="Node-Row-truncate">{text}</div>
<Tooltip text={text} position={position} copy={copy} className="Row-Tooltip">
<div className="Row-truncate">{text}</div>
</Tooltip>
);
}
@@ -24,5 +24,3 @@ class Truncate extends React.Component<Truncate.Props, {}> {
return this.props.text !== nextProps.text || this.props.position !== nextProps.position;
}
}
export default Truncate;
@@ -0,0 +1,3 @@
export * from './List';
export * from './Truncate';
export * from './Row';
@@ -1,5 +1,5 @@
.Node-Location {
.Location {
width: 6px;
height: 6px;
background: transparent;
@@ -15,7 +15,7 @@
transition: border-color 0.25s linear;
}
.Node-Location-dimmed {
.Location-dimmed {
width: 2px;
height: 2px;
margin-left: -1px;
@@ -25,34 +25,34 @@
border: none;
}
.Node-Location-ping {
.Location-ping {
pointer-events: none;
position: absolute;
display: none;
}
.Node-Location-odd {
.Location-odd {
border-color: #bbb;
}
.Node-Location-synced {
.Location-synced {
z-index: 3;
border-color: #d64ca8;
}
.Node-Location-synced .Node-Location-ping {
.Location-synced .Location-ping {
border: 1px solid #fff;
border-radius: 30px;
display: block;
animation: ping 1s forwards;
}
.Node-Location:hover {
.Location:hover {
z-index: 4;
border-color: #fff;
}
.Node-Location-details {
.Location-details {
min-width: 335px;
position: absolute;
font-family: monospace, sans-serif;
@@ -62,38 +62,38 @@
border-collapse: collapse;
}
.Node-Location-quarter0 .Node-Location-details {
.Location-quarter0 .Location-details {
left: 16px;
top: -4px;
}
.Node-Location-quarter1 .Node-Location-details {
.Location-quarter1 .Location-details {
right: 16px;
top: -4px;
}
.Node-Location-quarter2 .Node-Location-details {
.Location-quarter2 .Location-details {
left: 16px;
bottom: -4px;
}
.Node-Location-quarter3 .Node-Location-details {
.Location-quarter3 .Location-details {
right: 16px;
bottom: -4px;
}
.Node-Location-details td {
.Location-details td {
text-align: left;
padding: 0.5em 1em;
}
.Node-Location-details td:nth-child(odd) {
.Location-details td:nth-child(odd) {
width: 16px;
text-align: center;
padding-right: 0.2em;
}
.Node-Location-details td:nth-child(even) {
.Location-details td:nth-child(even) {
padding-left: 0.2em;
}
@@ -117,7 +117,7 @@
}
}
.Node-Location-validator {
.Location-validator {
display: inline-block;
width: 16px;
height: 16px;
@@ -16,7 +16,7 @@ import lastTimeIcon from '../../icons/watch.svg';
import './Location.css';
namespace Location {
export namespace Location {
export type Quarter = 0 | 1 | 2 | 3;
export interface Props {
@@ -36,7 +36,7 @@ namespace Location {
}
}
class Location extends React.Component<Location.Props, Location.State> {
export class Location extends React.Component<Location.Props, Location.State> {
public readonly state = { hover: false };
public render() {
@@ -48,16 +48,16 @@ class Location extends React.Component<Location.Props, Location.State> {
return null;
}
let className = `Node-Location Node-Location-quarter${quarter}`;
let className = `Location Location-quarter${quarter}`;
if (focused) {
if (propagationTime != null) {
className += ' Node-Location-synced';
className += ' Location-synced';
} else if (height % 2 === 1) {
className += ' Node-Location-odd';
className += ' Location-odd';
}
} else {
className += ' Node-Location-dimmed';
className += ' Location-dimmed';
}
return (
@@ -65,7 +65,7 @@ class Location extends React.Component<Location.Props, Location.State> {
{
this.state.hover ? this.renderDetails() : null
}
<div className="Node-Location-ping" />
<div className="Location-ping" />
</div>
);
}
@@ -92,14 +92,14 @@ class Location extends React.Component<Location.Props, Location.State> {
<td><Icon src={nodeValidatorIcon} alt="Node" /></td>
<td colSpan={5}>
{trimHash(validator, 30)}
<span className="Node-Location-validator"><Identicon id={validator} size={16} /></span>
<span className="Location-validator"><Identicon id={validator} size={16} /></span>
</td>
</tr>
);
}
return (
<table className="Node-Location-details Node-Location-details">
<table className="Location-details Location-details">
<tbody>
<tr>
<td><Icon src={nodeIcon} alt="Node" /></td><td colSpan={5}>{name}</td>
@@ -138,5 +138,3 @@ class Location extends React.Component<Location.Props, Location.State> {
this.setState({ hover: false });
}
}
export default Location;
@@ -0,0 +1,11 @@
.Map {
min-width: 1350px;
background: url('../../assets/world-map.svg') no-repeat;
background-size: contain;
background-position: center;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
@@ -0,0 +1,118 @@
import * as React from 'react';
import { Types, Maybe } from '@dotstats/common';
import { State as AppState, Node } from '../../state';
import { Location } from './';
import { viewport } from '../../utils';
const MAP_RATIO = 800 / 350;
const MAP_HEIGHT_ADJUST = 400 / 350;
const HEADER = 148;
import './Map.css';
export namespace Map {
export interface Props {
filter: Maybe<(node: Node) => boolean>;
appState: Readonly<AppState>;
}
export interface State {
width: number;
height: number;
top: number;
left: number;
}
}
export class Map extends React.Component<Map.Props, Map.State> {
public state = {
width: 0,
height: 0,
top: 0,
left: 0
}
public componentWillMount() {
this.onResize();
window.addEventListener('resize', this.onResize);
}
public componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
}
public render() {
const { filter, appState } = this.props;
const nodes = appState.nodes.sorted();
return (
<div className="Map">
{
nodes.map((node) => {
const { lat, lon } = node;
const focused = filter == null || filter(node);
if (lat == null || lon == null) {
// Skip nodes with unknown location
return null;
}
const position = this.pixelPosition(lat, lon);
return (
<Location key={node.id} position={position} focused={focused} node={node} />
);
})
}
</div>
);
}
private pixelPosition(lat: Types.Latitude, lon: Types.Longitude): Location.Position {
const { state } = this;
// Longitude ranges -180 (west) to +180 (east)
// Latitude ranges +90 (north) to -90 (south)
const left = Math.round(((180 + lon) / 360) * state.width + state.left);
const top = Math.round(((90 - lat) / 180) * state.height + state.top) * MAP_HEIGHT_ADJUST;
let quarter: Location.Quarter = 0;
if (lon > 0) {
quarter = (quarter | 1) as Location.Quarter;
}
if (lat < 0) {
quarter = (quarter | 2) as Location.Quarter;
}
return { left, top, quarter };
}
private onResize: () => void = () => {
const vp = viewport();
vp.width = Math.max(1350, vp.width);
vp.height -= HEADER;
const ratio = vp.width / vp.height;
let top = 0;
let left = 0;
let width = 0;
let height = 0;
if (ratio >= MAP_RATIO) {
width = Math.round(vp.height * MAP_RATIO);
height = Math.round(vp.height);
left = (vp.width - width) / 2;
} else {
width = Math.round(vp.width);
height = Math.round(vp.width / MAP_RATIO);
top = (vp.height - height) / 2;
}
this.setState({ top, left, width, height });
}
}
@@ -0,0 +1,2 @@
export * from './Map';
export * from './Location';
@@ -1,7 +0,0 @@
import Row from './Row';
import Location from './Location';
import Truncate from './Truncate';
export { Row, Location, Truncate };
export const SEMVER_PATTERN = /^\d+\.\d+\.\d+/;
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Icon } from './';
import { State } from '../state';
import { PersistentObject } from '../persist';
import { Icon } from '../';
import { State } from '../../state';
import { PersistentObject } from '../../persist';
import './Setting.css';
@@ -0,0 +1,17 @@
.Settings {
text-align: center;
}
.Settings-category {
text-align: left;
width: 500px;
margin: 0 auto;
padding: 2em 0;
}
.Settings-category h2 {
padding: 0;
margin: 0 0 0.5em 0;
font-size: 20px;
font-weight: 100;
}
@@ -0,0 +1,45 @@
import * as React from 'react';
import { Maybe } from '@dotstats/common';
import { State as AppState } from '../../state';
import { Setting } from './';
import { Row } from '../List';
import { PersistentObject } from '../../persist';
import './Settings.css';
export namespace Settings {
export type Display = 'list' | 'map' | 'settings';
export interface Props {
settings: PersistentObject<AppState.Settings>;
}
export interface State {
display: Display;
filter: Maybe<string>;
}
}
export class Settings extends React.Component<Settings.Props, {}> {
public render() {
const { settings } = this.props;
return (
<div className="Settings">
<div className="Settings-category">
<h2>Visible Columns</h2>
{
Row.columns
.map(({ label, icon, setting }, index) => {
if (!setting) {
return null;
}
return <Setting key={index} setting={setting} settings={settings} icon={icon} label={label} />;
})
}
</div>
</div>
);
}
}
@@ -0,0 +1,2 @@
export * from './Settings';
export * from './Setting';
+3 -5
View File
@@ -1,13 +1,11 @@
export * from './Chains';
export * from './Chain';
export * from './List';
export * from './Map';
export * from './Settings';
export * from './Icon';
export * from './Tile';
export * from './Ago';
export * from './OfflineIndicator';
export * from './Setting';
export * from './Sparkline';
export * from './Tooltip';
import * as Node from './Node';
export { Node };