mirror of
https://github.com/pezkuwichain/pezkuwi-telemetry.git
synced 2026-05-06 03:07:58 +00:00
New List, Map, and Settings components (#86)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -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);
|
||||
}
|
||||
+21
-17
@@ -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 (
|
||||
+4
-6
@@ -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';
|
||||
+16
-16
@@ -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;
|
||||
+9
-11
@@ -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+/;
|
||||
+3
-3
@@ -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';
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user