Add /_stats endpoint

Also, remove ingest_stats worker and use the cache instead.
This commit is contained in:
MICHAEL JACKSON
2017-08-22 08:31:33 -07:00
parent c4f3d5bbbc
commit 2a0d32f214
32 changed files with 555 additions and 18278 deletions

View File

@ -1,8 +1,7 @@
import React from 'react'
import contentHTML from './About.md'
function About() {
return <div className="wrapper" dangerouslySetInnerHTML={{ __html: contentHTML }}/>
}
const About = () =>
<div className="wrapper" dangerouslySetInnerHTML={{ __html: contentHTML }}/>
export default About

View File

@ -1,12 +1,4 @@
unpkg is an [open source](https://github.com/mjackson/unpkg) project built by me, [Michael Jackson](https://twitter.com/mjackson). I built it because, as an npm package author, it felt tedious for me to use existing, git-based CDNs to make my open source work available via CDN. Development was sponsored by my company, [React Training](https://reacttraining.com).
<div class="about-logos">
<div class="about-logo">
<a href="https://reacttraining.com"><img src="./ReactTrainingLogo.png"></a>
</div>
</div>
We'd love to talk to you more about training your team on [React](https://facebook.github.io/react/). Please [get in touch](mailto:hello@reacttraining.com) if interested.
unpkg is an [open source](https://github.com/unpkg) project built and maintained by [Michael Jackson](https://twitter.com/mjackson).
### Sponsors
@ -21,8 +13,6 @@ The fast, global infrastructure that powers unpkg is generously donated by [Clou
</div>
</div>
These sponsors provide some of the most robust, reliable infrastructure available today and I'm happy to be able to partner with them on unpkg.
### Cache Behavior
The CDN caches all files based on their permanent URL, which includes the npm package version. This works because npm does not allow package authors to overwrite a package that has already been published with a different one at the same version number.

View File

@ -1,20 +1,10 @@
import React from 'react'
import { HashRouter as Router, Switch, Route } from 'react-router-dom'
import { HashRouter } from 'react-router-dom'
import Layout from './Layout'
import About from './About'
import Stats from './Stats'
import Home from './Home'
const App = () => (
<Router>
<Layout>
<Switch>
<Route path="/stats" component={Stats}/>
<Route path="/about" component={About}/>
<Route path="/" component={Home}/>
</Switch>
</Layout>
</Router>
)
const App = () =>
<HashRouter>
<Layout/>
</HashRouter>
export default App

View File

@ -1,13 +0,0 @@
import {
continents as ContinentsIndex,
countries as CountriesIndex
} from 'countries-list'
const getCountriesByContinent = (continent) =>
Object.keys(CountriesIndex).filter(country => CountriesIndex[country].continent === continent)
export {
ContinentsIndex,
CountriesIndex,
getCountriesByContinent
}

View File

@ -1,15 +0,0 @@
export const addEvent = (node, type, handler) => {
if (node.addEventListener) {
node.addEventListener(type, handler, false)
} else if (node.attachEvent) {
node.attachEvent('on' + type, handler)
}
}
export const removeEvent = (node, type, handler) => {
if (node.removeEventListener) {
node.removeEventListener(type, handler, false)
} else if (node.detachEvent) {
node.detachEvent('on' + type, handler)
}
}

View File

@ -1,8 +1,7 @@
import React from 'react'
import contentHTML from './Home.md'
function Home() {
return <div className="wrapper" dangerouslySetInnerHTML={{ __html: contentHTML }}/>
}
const Home = () =>
<div className="wrapper" dangerouslySetInnerHTML={{ __html: contentHTML }}/>
export default Home

View File

@ -1,60 +1,43 @@
unpkg is a fast, global [content delivery network](https://en.wikipedia.org/wiki/Content_delivery_network) for everything on [npm](https://www.npmjs.com/). Use it to quickly and easily load files using a simple URL like:
unpkg is a fast, global [content delivery network](https://en.wikipedia.org/wiki/Content_delivery_network) for everything on [npm](https://www.npmjs.com/). Use it to quickly and easily load any file from any package using a URL like:
<div style="text-align:center">`https://unpkg.com/package@version/file`</div>
<div class="home-example">unpkg.com/:package@:version/:file</div>
A few examples:
### Examples
* [https://unpkg.com/react@15.3.1/dist/react.min.js](/react@15.3.1/dist/react.min.js)
* [https://unpkg.com/react-dom@15.3.1/dist/react-dom.min.js](/react-dom@15.3.1/dist/react-dom.min.js)
* [https://unpkg.com/history@4.2.0/umd/history.min.js](/history@4.2.0/umd/history.min.js)
Using a fixed version:
You may also use a [tag](https://docs.npmjs.com/cli/dist-tag) or [version range](https://docs.npmjs.com/misc/semver) instead of a fixed version number, or omit the version/tag entirely to use the `latest` tag.
* [unpkg.com/react@15.3.1/dist/react.min.js](/react@15.3.1/dist/react.min.js)
* [unpkg.com/react-dom@15.3.1/dist/react-dom.min.js](/react-dom@15.3.1/dist/react-dom.min.js)
* [https://unpkg.com/react@^0.14/dist/react.min.js](/react@^0.14/dist/react.min.js)
* [https://unpkg.com/react/dist/react.min.js](/react/dist/react.min.js)
You may also use a [semver range](https://docs.npmjs.com/misc/semver) or a [tag](https://docs.npmjs.com/cli/dist-tag) instead of a fixed version number, or omit the version/tag entirely to use the `latest` tag.
If you omit the file path, unpkg will try to serve [the `browser` bundle](https://github.com/defunctzombie/package-browser-field-spec) if present, the [`main` module](https://docs.npmjs.com/files/package.json#main) otherwise.
* [unpkg.com/react@^15/dist/react.min.js](/react@^15/dist/react.min.js)
* [unpkg.com/react/dist/react.min.js](/react/dist/react.min.js)
* [https://unpkg.com/jquery](/jquery)
* [https://unpkg.com/angular-formly](/angular-formly)
* [https://unpkg.com/three](/three)
If you omit the file path, unpkg will serve the package's "main" file.
* [unpkg.com/jquery](/jquery)
* [unpkg.com/three](/three)
Append a `/` at the end of a URL to view a listing of all the files in a package.
* [https://unpkg.com/lodash/](/lodash/)
* [https://unpkg.com/modernizr/](/modernizr/)
* [https://unpkg.com/react/](/react/)
* [unpkg.com/react/](/react/)
* [unpkg.com/lodash/](/lodash/)
### Query Parameters
<table cellpadding="0" cellspacing="0">
<thead>
<tr>
<th width="80px">Name</th>
<th width="120px">Default Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>`main`</td>
<td>`unpkg`, `browser`, `main`</td>
<td>The name of the field in [package.json](https://docs.npmjs.com/files/package.json) to use as the main entry point when there is no file path in the URL</td>
</tr>
<tr>
<td>`meta`</td>
<td></td>
<td>Return metadata about any file in a package as JSON (e.g. `/any/file?meta`)</td>
</tr>
<tr>
<td>`expand`</td>
<td></td>
<td>Expands all ["bare" `import` specifiers](https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier) in JavaScript modules to unpkg URLs. This feature is *very experimental*</td>
</tr>
</tbody>
</table>
<dl>
<dt>`?main=:mainField`</dt>
<dd>The name of the field in [package.json](https://docs.npmjs.com/files/package.json) to use as the main entry point when there is no file path in the URL. Defaults to using `unpkg`, [`browser`](https://github.com/defunctzombie/package-browser-field-spec), and then [`main`](https://docs.npmjs.com/files/package.json#main).</dd>
### Suggested Workflow
<dt>`?meta`</dt>
<dd>Return metadata about any file in a package as JSON (e.g. `/any/file?meta`)</dd>
<dt>`?module`</dt>
<dd>Expands all ["bare" `import` specifiers](https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier) in JavaScript modules to unpkg URLs. This feature is *very experimental*</dd>
</dl>
### Workflow
For npm package authors, unpkg relieves the burden of publishing your code to a CDN in addition to the npm registry. All you need to do is include your [UMD](https://github.com/umdjs/umd) build in your npm package (not your repo, that's different!).

View File

@ -1,9 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'
import { Motion, spring } from 'react-motion'
import { withRouter, Link } from 'react-router-dom'
import { Switch, Route, Link, withRouter } from 'react-router-dom'
import WindowSize from './WindowSize'
import About from './About'
import Stats from './Stats'
import Home from './Home'
class Layout extends React.Component {
static propTypes = {
@ -14,24 +16,25 @@ class Layout extends React.Component {
state = {
underlineLeft: 0,
underlineWidth: 0,
useSpring: false
useSpring: false,
stats: null
}
adjustUnderline = (useSpring = false) => {
let itemIndex
switch (this.props.location.pathname) {
case '/about':
itemIndex = 2
break
case '/stats':
itemIndex = 1
break
case '/about':
itemIndex = 2
break
case '/':
default:
itemIndex = 0
}
const itemNodes = ReactDOM.findDOMNode(this).querySelectorAll('.underlist > li')
const itemNodes = this.listNode.querySelectorAll('li')
const currentNode = itemNodes[itemIndex]
this.setState({
@ -43,6 +46,10 @@ class Layout extends React.Component {
componentDidMount() {
this.adjustUnderline()
fetch('/_stats/last-month')
.then(res => res.json())
.then(stats => this.setState({ stats }))
}
componentDidUpdate(prevProps) {
@ -63,34 +70,36 @@ class Layout extends React.Component {
<WindowSize onChange={this.adjustUnderline}/>
<div className="wrapper">
<header>
<h1>unpkg</h1>
<nav>
<ol className="underlist">
<h1 className="layout-title">unpkg</h1>
<nav className="layout-nav">
<ol className="layout-nav-list" ref={node => this.listNode = node}>
<li><Link to="/">Home</Link></li>
<li><Link to="/stats">Stats</Link></li>
<li><Link to="/about">About</Link></li>
</ol>
<Motion defaultStyle={{ left: underlineLeft, width: underlineWidth }} style={style}>
{s => (
<Motion
defaultStyle={{ left: underlineLeft, width: underlineWidth }}
style={style}
children={style => (
<div
className="underlist-underline"
className="layout-nav-underline"
style={{
WebkitTransform: `translate3d(${s.left}px,0,0)`,
transform: `translate3d(${s.left}px,0,0)`,
width: s.width
WebkitTransform: `translate3d(${style.left}px,0,0)`,
transform: `translate3d(${style.left}px,0,0)`,
width: style.width
}}
/>
)}
</Motion>
/>
</nav>
</header>
</div>
{this.props.children}
<div className="wrapper">
<footer>
<p>&copy; 2016-{(new Date()).getFullYear()} Michael Jackson</p>
</footer>
</div>
<Switch>
<Route path="/stats" render={() => <Stats data={this.state.stats}/>}/>
<Route path="/about" component={About}/>
<Route path="/" component={Home}/>
</Switch>
</div>
)
}

View File

@ -1,15 +0,0 @@
export const formatNumber = (n) => {
const digits = String(n).split('')
const groups = []
while (digits.length)
groups.unshift(digits.splice(-3).join(''))
return groups.join(',')
}
export const parseNumber = (s) =>
parseInt(s.replace(/,/g, ''), 10) || 0
export const formatPercent = (n, fixed = 1) =>
String((n.toPrecision(2) * 100).toFixed(fixed))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -3,115 +3,125 @@ import PropTypes from 'prop-types'
import formatBytes from 'pretty-bytes'
import formatDate from 'date-fns/format'
import parseDate from 'date-fns/parse'
import { formatNumber, formatPercent } from './NumberUtils'
import { ContinentsIndex, CountriesIndex, getCountriesByContinent } from './CountryUtils'
import NumberTextInput from './NumberTextInput'
import formatNumber from './utils/formatNumber'
import formatPercent from './utils/formatPercent'
const getSum = (data, countries) =>
countries.reduce((n, country) => n + (data[country] || 0), 0)
import { continents, countries } from 'countries-list'
const addValues = (a, b) => {
for (const p in b) {
if (p in a) {
a[p] += b[p]
} else {
a[p] = b[p]
}
}
}
const getCountriesByContinent = (continent) =>
Object.keys(countries).filter(country => countries[country].continent === continent)
const sumKeyValues = (hash, keys) =>
keys.reduce((n, key) => n + (hash[key] || 0), 0)
const sumValues = (hash) =>
Object.keys(hash).reduce((memo, key) => memo + hash[key], 0)
class Stats extends React.Component {
static propTypes = {
serverData: PropTypes.object
}
static defaultProps = {
serverData: window.serverData
data: PropTypes.object
}
state = {
minRequests: 5000000
}
updateMinRequests = (value) => {
this.setState({ minRequests: value })
minPackageRequests: 100000,
minCountryRequests: 1000000
}
render() {
const { minRequests } = this.state
const stats = this.props.serverData.cloudflareStats
const { timeseries, totals } = stats
const { data } = this.props
if (data == null)
return null
const totals = data.totals
// Summary data
const sinceDate = parseDate(totals.since)
const untilDate = parseDate(totals.until)
const uniqueVisitors = totals.uniques.all
const since = parseDate(totals.since)
const until = parseDate(totals.until)
const totalRequests = totals.requests.all
const cachedRequests = totals.requests.cached
const totalBandwidth = totals.bandwidth.all
const httpStatus = totals.requests.http_status
// Packages
const packageRows = []
let errorRequests = 0
for (const status in httpStatus) {
if (httpStatus.hasOwnProperty(status) && status >= 500)
errorRequests += httpStatus[status]
}
Object.keys(totals.requests.package).sort((a, b) => {
return totals.requests.package[b] - totals.requests.package[a]
}).forEach(packageName => {
const requests = totals.requests.package[packageName]
const bandwidth = totals.bandwidth.package[packageName]
// By Region
const regionRows = []
const requestsByCountry = {}
const bandwidthByCountry = {}
timeseries.forEach(ts => {
addValues(requestsByCountry, ts.requests.country)
addValues(bandwidthByCountry, ts.bandwidth.country)
if (requests >= this.state.minPackageRequests) {
packageRows.push(
<tr key={packageName}>
<td><a href={`https://npmjs.org/package/${packageName}`} title={`${packageName} on npm`}>{packageName}</a></td>
<td>{formatNumber(requests)} ({formatPercent(requests / totals.requests.all)}%)</td>
{bandwidth
? <td>{formatBytes(bandwidth)} ({formatPercent(bandwidth / totals.bandwidth.all)}%)</td>
: <td>-</td>
}
</tr>
)
}
})
const byRequestsDescending = (a, b) =>
requestsByCountry[b] - requestsByCountry[a]
// Protocols
const protocolRows = Object.keys(totals.requests.protocol).sort((a, b) => {
return totals.requests.protocol[b] - totals.requests.protocol[a]
}).map(protocol => {
const requests = totals.requests.protocol[protocol]
const continentData = Object.keys(ContinentsIndex).reduce((memo, continent) => {
const countries = getCountriesByContinent(continent)
return (
<tr key={protocol}>
<td>{protocol}</td>
<td>{formatNumber(requests)} ({formatPercent(requests / sumValues(totals.requests.protocol))}%)</td>
</tr>
)
})
// Regions
const regionRows = []
const continentsData = Object.keys(continents).reduce((memo, continent) => {
const localCountries = getCountriesByContinent(continent)
memo[continent] = {
countries,
requests: getSum(requestsByCountry, countries),
bandwidth: getSum(bandwidthByCountry, countries)
countries: localCountries,
requests: sumKeyValues(totals.requests.country, localCountries),
bandwidth: sumKeyValues(totals.bandwidth.country, localCountries)
}
return memo
}, {})
const topContinents = Object.keys(continentData).sort((a, b) => {
return continentData[b].requests - continentData[a].requests
const topContinents = Object.keys(continentsData).sort((a, b) => {
return continentsData[b].requests - continentsData[a].requests
})
topContinents.forEach(continent => {
const continentName = ContinentsIndex[continent]
const { countries, requests, bandwidth } = continentData[continent]
const continentName = continents[continent]
const continentData = continentsData[continent]
if (bandwidth !== 0) {
if (continentData.requests > this.state.minCountryRequests && continentData.bandwidth !== 0) {
regionRows.push(
<tr key={continent} className="continent-row">
<td>{continentName}</td>
<td>{formatNumber(requests)} ({formatPercent(requests / totalRequests)}%)</td>
<td>{formatBytes(bandwidth)} ({formatPercent(bandwidth / totalBandwidth)}%)</td>
<td>{formatNumber(continentData.requests)} ({formatPercent(continentData.requests / totals.requests.all)}%)</td>
<td>{formatBytes(continentData.bandwidth)} ({formatPercent(continentData.bandwidth / totals.bandwidth.all)}%)</td>
</tr>
)
const topCountries = countries.sort(byRequestsDescending)
const topCountries = continentData.countries.sort((a, b) => {
return totals.requests.country[b] - totals.requests.country[a]
})
topCountries.forEach(country => {
const countryRequests = requestsByCountry[country]
const countryBandwidth = bandwidthByCountry[country]
const countryRequests = totals.requests.country[country]
const countryBandwidth = totals.bandwidth.country[country]
if (countryRequests > minRequests) {
if (countryRequests > this.state.minCountryRequests) {
regionRows.push(
<tr key={continent + country} className="country-row">
<td className="country-name">{CountriesIndex[country].name}</td>
<td>{formatNumber(countryRequests)} ({formatPercent(countryRequests / totalRequests)}%)</td>
<td>{formatBytes(countryBandwidth)} ({formatPercent(countryBandwidth / totalBandwidth)}%)</td>
<td className="country-name">{countries[country].name}</td>
<td>{formatNumber(countryRequests)} ({formatPercent(countryRequests / totals.requests.all)}%)</td>
<td>{formatBytes(countryBandwidth)} ({formatPercent(countryBandwidth / totals.bandwidth.all)}%)</td>
</tr>
)
}
@ -121,16 +131,67 @@ class Stats extends React.Component {
return (
<div className="wrapper">
<p>From <strong>{formatDate(sinceDate, 'MMM D')}</strong> to <strong>{formatDate(untilDate, 'MMM D')}</strong>, unpkg served <strong>{formatNumber(totalRequests)}</strong> requests to <strong>{formatNumber(uniqueVisitors)}</strong> unique visitors, <strong>{formatPercent(cachedRequests / totalRequests, 0)}%</strong> of which came from the cache (CDN). Over the same period, <strong>{formatPercent(errorRequests / totalRequests, 4)}%</strong> of requests resulted in server error (returned an HTTP status &ge; 500).</p>
<p>From <strong>{formatDate(since, 'MMM D')}</strong> to <strong>{formatDate(until, 'MMM D')}</strong> unpkg served <strong>{formatNumber(totals.requests.all)}</strong> requests and a total of <strong>{formatBytes(totals.bandwidth.all)}</strong> of data to <strong>{formatNumber(totals.uniques.all)}</strong> unique visitors, <strong>{formatPercent(totals.requests.cached / totals.requests.all, 0)}%</strong> of which were served from the cache.</p>
<h3>By Region</h3>
<h3>Packages</h3>
<label className="table-filter">Include countries that made at least <NumberTextInput value={minRequests} onChange={this.updateMinRequests}/> requests.</label>
<p className="table-filter">Include only packages that received at least <select
value={this.state.minPackageRequests}
onChange={event => this.setState({ minPackageRequests: parseInt(event.target.value, 10) })}
>
<option value="0">0</option>
<option value="1000">1,000</option>
<option value="10000">10,000</option>
<option value="100000">100,000</option>
<option value="1000000">1,000,000</option>
</select> requests.
</p>
<table cellSpacing="0" cellPadding="0" style={{ width: '100%' }}>
<thead>
<tr>
<th>Name</th>
<th>Package</th>
<th>Requests (% of total)</th>
<th>Bandwidth (% of total)</th>
</tr>
</thead>
<tbody>
{packageRows}
</tbody>
</table>
<h3>Protocols</h3>
<table cellSpacing="0" cellPadding="0" style={{ width: '100%' }}>
<thead>
<tr>
<th>Protocol</th>
<th>Requests (% of total)</th>
</tr>
</thead>
<tbody>
{protocolRows}
</tbody>
</table>
<h3>Regions</h3>
<p className="table-filter">Include only countries that made at least <select
value={this.state.minCountryRequests}
onChange={event => this.setState({ minCountryRequests: parseInt(event.target.value, 10) })}
>
<option value="0">0</option>
<option value="100000">100,000</option>
<option value="1000000">1,000,000</option>
<option value="10000000">10,000,000</option>
<option value="100000000">100,000,000</option>
</select> requests.
</p>
<table cellSpacing="0" cellPadding="0" style={{ width: '100%' }} className="regions-table">
<thead>
<tr>
<th>Region</th>
<th>Requests (% of total)</th>
<th>Bandwidth (% of total)</th>
</tr>

View File

@ -1,6 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import { addEvent, removeEvent } from './DOMUtils'
import addEvent from './utils/addEvent'
import removeEvent from './utils/removeEvent'
const ResizeEvent = 'resize'

View File

@ -11,9 +11,9 @@ body {
padding: 5px 20px;
}
@media all and (min-width: 660px) {
@media (min-width: 800px) {
body {
padding: 50px 20px;
padding: 40px 20px 120px;
}
}
@ -24,94 +24,103 @@ a:visited {
color: rebeccapurple;
}
code {
background: #eee;
h1 {
font-size: 2em;
}
h2 {
font-size: 1.8em;
}
h3 {
font-size: 1.6em;
}
ul {
padding-left: 25px;
}
dd {
margin-left: 25px;
}
table {
border-color: black;
border-style: solid;
border-width: 0 0 1px 1px;
border: 1px solid black;
border: 0;
}
table th, table td {
th {
text-align: left;
background-color: #eee;
}
th, td {
padding: 5px;
}
th {
vertical-align: bottom;
}
td {
vertical-align: top;
padding: 5px 7px;
border-color: black;
border-style: solid;
border-width: 1px 1px 0 0;
}
.continent-row {
font-weight: bold;
}
.country-name {
padding-left: 2em;
}
.table-filter {
display: block;
text-align: right;
color: #666;
margin-bottom: 1em;
}
.table-filter input {
font-size: 1em;
text-align: right;
max-width: 100px;
color: #999;
}
.wrapper {
max-width: 600px;
max-width: 700px;
margin: 0 auto;
}
h1, h2, h3, header nav {
font-family: Futura, Helvetica, sans-serif;
text-transform: uppercase;
}
h3 {
margin-top: 2em;
}
header h1 {
font-size: 4em;
line-height: 1;
text-align: center;
letter-spacing: 0.1em;
}
header nav {
margin-bottom: 4em;
}
header nav a:link,
header nav a:visited {
color: black;
}
.underlist {
list-style-type: none;
padding: 0;
.layout-title {
margin: 0;
text-transform: uppercase;
text-align: center;
font-size: 5em;
}
.layout-nav {
margin: 0 0 3em;
}
.layout-nav-list {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
}
.underlist li {
margin: 0 5px;
padding: 0 5px;
.layout-nav-list li {
flex-basis: auto;
list-style-type: none;
display: inline-block;
font-size: 1.1em;
margin: 0 10px;
}
.underlist a:link {
.layout-nav-list li a:link {
text-decoration: none;
}
.underlist-underline {
.layout-nav-list li a:link,
.layout-nav-list li a:visited {
color: black;
}
.layout-nav-underline {
height: 4px;
background-color: black;
position: absolute;
left: 0;
}
.home-example {
text-align: center;
background-color: #eee;
margin: 2em 0;
padding: 5px 0;
}
.table-filter {
font-size: 0.8em;
text-align: right;
}
.regions-table .continent-row {
font-weight: bold;
}
.regions-table .country-row td.country-name {
padding-left: 20px;
}
.about-logos {
margin: 2em 0;
display: flex;
@ -125,10 +134,3 @@ header nav a:visited {
.about-logo img {
max-width: 60%;
}
footer {
margin-top: 40px;
border-top: 1px solid black;
font-size: 0.8em;
text-align: right;
}

9
client/utils/addEvent.js Normal file
View File

@ -0,0 +1,9 @@
const addEvent = (node, type, handler) => {
if (node.addEventListener) {
node.addEventListener(type, handler, false)
} else if (node.attachEvent) {
node.attachEvent('on' + type, handler)
}
}
export default addEvent

View File

@ -0,0 +1,11 @@
const formatNumber = (n) => {
const digits = String(n).split('')
const groups = []
while (digits.length)
groups.unshift(digits.splice(-3).join(''))
return groups.join(',')
}
export default formatNumber

View File

@ -0,0 +1,4 @@
const formatPercent = (n, fixed = 1) =>
String((n.toPrecision(2) * 100).toFixed(fixed))
export default formatPercent

View File

@ -0,0 +1,4 @@
const parseNumber = (s) =>
parseInt(s.replace(/,/g, ''), 10) || 0
export default parseNumber

View File

@ -0,0 +1,9 @@
const removeEvent = (node, type, handler) => {
if (node.removeEventListener) {
node.removeEventListener(type, handler, false)
} else if (node.detachEvent) {
node.detachEvent('on' + type, handler)
}
}
export default removeEvent