diff --git a/stocks@infinicode.de/components/cards/stockCard.js b/stocks@infinicode.de/components/cards/stockCard.js index 7a26a19..938f87f 100644 --- a/stocks@infinicode.de/components/cards/stockCard.js +++ b/stocks@infinicode.de/components/cards/stockCard.js @@ -212,7 +212,7 @@ var StockCard = GObject.registerClass({ } _createPostMarketAdditionalInformationBox () { - const quoteColorStyleClass = getStockColorStyleClass(this.cardItem.PreMarketChange) + const quoteColorStyleClass = getStockColorStyleClass(this.cardItem.PostMarketChange) const additionalInformationBox = new St.BoxLayout({ style_class: 'info-section-box tar', diff --git a/stocks@infinicode.de/components/chart/chart.js b/stocks@infinicode.de/components/chart/chart.js index 231ff35..b30c06a 100644 --- a/stocks@infinicode.de/components/chart/chart.js +++ b/stocks@infinicode.de/components/chart/chart.js @@ -3,7 +3,7 @@ const { Clutter, GObject, St } = imports.gi const ExtensionUtils = imports.misc.extensionUtils const Me = ExtensionUtils.getCurrentExtension() -const { closest, isNullOrEmpty, isNullOrUndefined } = Me.imports.helpers.data +const { closest, fallbackIfNaN, isNullOrEmpty, isNullOrUndefined, getComplementaryColor } = Me.imports.helpers.data var Chart = GObject.registerClass({ GTypeName: 'StockExtension_Chart', @@ -13,16 +13,21 @@ var Chart = GObject.registerClass({ } } }, class Chart extends St.DrawingArea { - _init ({ data, x1, x2 }) { + _init ({ data, x1, x2, barData }) { super._init({ style_class: 'chart', reactive: true }) this.data = data + this.barData = barData + this.x1 = x1 this.x2 = x2 + this._selectedX = null + this._selectedY = null + this.connect('repaint', this._draw.bind(this)) this.connect('motion-event', (item, event) => this._onHover(item, event)) } @@ -39,28 +44,46 @@ var Chart = GObject.registerClass({ this.width = width this.height = height + // get primary color from themes const themeNode = this.get_theme_node() + // FIXME: it would be nice to have some basic color sets in gnome-shell + const fgColor = themeNode.get_foreground_color() + const newColorString = getComplementaryColor(fgColor.to_string().slice(1, 7), false) + const secondaryColor = Clutter.color_from_string(`${newColorString}ff`)[1] + + const baseParams = { + cairoContext, + width, + height, + primaryColor: fgColor, + secondaryColor: secondaryColor + } + + this._draw_line_chart(baseParams) + this._draw_volume_bars(baseParams) + this._draw_crosshair(baseParams) + + // dispose cairo stuff + cairoContext.$dispose() + } + + _draw_line_chart ({ width, height, cairoContext, primaryColor }) { // scale data to width / height of our cairo canvas const seriesData = this._transformSeriesData(this.data, width, height) - // get primary color from themes - const fgColor = themeNode.get_foreground_color() - Clutter.cairo_set_source_color(cairoContext, fgColor) + Clutter.cairo_set_source_color(cairoContext, primaryColor) // get first data const [firstValueX, firstValueY] = [0, 0] // tell cairo where to start drawing - cairoContext.moveTo( - firstValueX, - height - firstValueY - ) + cairoContext.moveTo(firstValueX, height - firstValueY) let lastValueX = firstValueX seriesData.forEach(([valueX, valueY]) => { - if (isNullOrUndefined(valueX) || isNullOrUndefined(valueY)) { + if (isNullOrUndefined(valueX) || isNullOrUndefined(fallbackIfNaN(valueY, null))) { return } @@ -76,9 +99,59 @@ var Chart = GObject.registerClass({ // render cairoContext.fill() + } - // dispose cairo stuff - cairoContext.$dispose() + _draw_volume_bars ({ width, height, cairoContext, secondaryColor }) { + if (isNullOrEmpty(this.barData)) { + return + } + + const volumeBarsHeight = height * 0.20 // use the 20% space at bottom + const seriesData = this._transformSeriesData(this.barData, width, volumeBarsHeight) + + const barWidth = 3 + const barWidthPerSide = barWidth / 3 // left, middle, right + + Clutter.cairo_set_source_color(cairoContext, secondaryColor) + + cairoContext.moveTo(0, height) + + seriesData.forEach(([valueX, valueY]) => { + if (isNullOrUndefined(valueX) || isNullOrUndefined(fallbackIfNaN(valueY, null))) { + return + } + + const x_start = valueX - barWidthPerSide + const x_end = valueX + barWidthPerSide + + cairoContext.lineTo(x_start, height) + cairoContext.lineTo(x_start, height - valueY) + cairoContext.lineTo(x_end, height - valueY) + cairoContext.lineTo(x_end, height) + }) + + cairoContext.lineTo(0, height) + cairoContext.fill() + } + + _draw_crosshair ({ width, height, cairoContext, secondaryColor }) { + if (this._selectedX) { + Clutter.cairo_set_source_color(cairoContext, secondaryColor) + cairoContext.moveTo(this._selectedX - 1, 0) + cairoContext.lineTo(this._selectedX - 1, height) + cairoContext.lineTo(this._selectedX, height) + cairoContext.lineTo(this._selectedX, 0) + cairoContext.fill() + } + + if (this._selectedY) { + Clutter.cairo_set_source_color(cairoContext, secondaryColor) + cairoContext.moveTo(0, this._selectedY - 1) + cairoContext.lineTo(width, this._selectedY - 1) + cairoContext.lineTo(width, this._selectedY) + cairoContext.lineTo(0, this._selectedY) + cairoContext.fill() + } } _transformSeriesData (data, width, height) { @@ -91,9 +164,12 @@ var Chart = GObject.registerClass({ const yValues = [...data.filter(item => item[1] !== null).map(item => item[1])] - const minValueY = Math.min(...yValues) + let minValueY = Math.min(...yValues) const maxValueY = Math.max(...yValues) + // add small buffer to bottom + minValueY -= (maxValueY - minValueY) * 0.25 + return data.map(([x, y]) => [ this.encodeValue(x, minValueX, maxValueX, 0, width), isNullOrUndefined(y) ? null : this.encodeValue(y, minValueY, maxValueY, 0, height) @@ -109,10 +185,11 @@ var Chart = GObject.registerClass({ // then convert the position data back to original x value (timestamp) // find by this timestamp the closest item in series data - const [coordX] = event.get_coords() - const [positionX] = item.get_transformed_position() + const [coordX, coordY] = event.get_coords() + const [positionX, positionY] = item.get_transformed_position() const chartX = coordX - positionX + const chartY = coordY - positionY const minX = this.x1 || this.data[0][0] const maxX = this.x2 || this.data[this.data.length - 1][0] @@ -121,6 +198,11 @@ var Chart = GObject.registerClass({ const tsItem = this.data.find(data => data[0] === originalValueX) this.emit('chart-hover', tsItem[0], tsItem[1]) + + this._selectedX = chartX + this._selectedY = chartY + + this.queue_repaint() } // thx: https://stackoverflow.com/a/5732390/3828502 diff --git a/stocks@infinicode.de/components/screens/stockDetailsScreen/stockDetailsScreen.js b/stocks@infinicode.de/components/screens/stockDetailsScreen/stockDetailsScreen.js index 4bb968b..254109d 100644 --- a/stocks@infinicode.de/components/screens/stockDetailsScreen/stockDetailsScreen.js +++ b/stocks@infinicode.de/components/screens/stockDetailsScreen/stockDetailsScreen.js @@ -61,7 +61,12 @@ var StockDetailsScreen = GObject.registerClass({}, class StockDetailsScreen exte this._sync() }) - const chart = new Chart({ data: quoteHistorical.Data, x1: quoteHistorical.MarketStart, x2: quoteHistorical.MarketEnd }) + const chart = new Chart({ + data: quoteHistorical.Data, + x1: quoteHistorical.MarketStart, + x2: quoteHistorical.MarketEnd, + barData: quoteHistorical.VolumeData + }) const chartValueLabel = new St.Label({ style_class: 'chart-hover-label', text: `` }) diff --git a/stocks@infinicode.de/helpers/data.js b/stocks@infinicode.de/helpers/data.js index 78e1ffe..4324952 100644 --- a/stocks@infinicode.de/helpers/data.js +++ b/stocks@infinicode.de/helpers/data.js @@ -5,7 +5,7 @@ const CACHE_TIME = 10 * 1000 var isNullOrUndefined = value => typeof value === 'undefined' || value === null var isNullOrEmpty = value => isNullOrUndefined(value) || value.length === 0 -var fallbackIfNaN = value => typeof value === 'undefined' || value === null || isNaN(value) ? '--' : value +var fallbackIfNaN = (value, fallback = '--') => typeof value === 'undefined' || value === null || isNaN(value) ? fallback : value var closest = (array, target) => array.reduce((prev, curr) => Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev) @@ -56,5 +56,38 @@ var getStockColorStyleClass = change => { return quoteColorStyleClass } -var roundOrDefault = (number, defaultValue = '--') => isNullOrUndefined(number) ? defaultValue : (Math.round((number + Number.EPSILON) * 100) / 100).toFixed(2) +var getComplementaryColor = (hex, bw = true) => { + const padZero = (str, len) => { + len = len || 2 + var zeros = new Array(len).join('0') + return (zeros + str).slice(-len) + } + if (hex.indexOf('#') === 0) { + hex = hex.slice(1) + } + // convert 3-digit hex to 6-digits. + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + } + if (hex.length !== 6) { + throw new Error('Invalid HEX color.') + } + var r = parseInt(hex.slice(0, 2), 16), + g = parseInt(hex.slice(2, 4), 16), + b = parseInt(hex.slice(4, 6), 16) + if (bw) { + // http://stackoverflow.com/a/3943023/112731 + return (r * 0.299 + g * 0.587 + b * 0.114) > 186 + ? '#000000' + : '#FFFFFF' + } + // invert color components + r = (255 - r).toString(16) + g = (255 - g).toString(16) + b = (255 - b).toString(16) + // pad each with zeros and return + return '#' + padZero(r) + padZero(g) + padZero(b) +} + +var roundOrDefault = (number, defaultValue = '--') => isNullOrUndefined(number) ? defaultValue : (Math.round((number + Number.EPSILON) * 100) / 100).toFixed(2) diff --git a/stocks@infinicode.de/metadata.json b/stocks@infinicode.de/metadata.json index bca8098..6840d0b 100644 --- a/stocks@infinicode.de/metadata.json +++ b/stocks@infinicode.de/metadata.json @@ -11,5 +11,5 @@ ], "url": "https://github.com/cinatic/stocks-extension", "uuid": "stocks@infinicode.de", - "version": 13 + "version": 14 } diff --git a/stocks@infinicode.de/services/dto/quoteHistorical.js b/stocks@infinicode.de/services/dto/quoteHistorical.js index ea4d701..f9791bc 100644 --- a/stocks@infinicode.de/services/dto/quoteHistorical.js +++ b/stocks@infinicode.de/services/dto/quoteHistorical.js @@ -3,6 +3,7 @@ var QuoteHistorical = class QuoteSummary { this.MarketStart = null this.MarketEnd = null this.Data = [] + this.VolumeData = [] this.Error = null } } @@ -16,8 +17,15 @@ var createQuoteHistoricalFromYahooData = (responseData, error) => { const result = responseData.chart.result[0] const timestamps = result.timestamp || [] const quotes = (result.indicators.quote || [])[0].close || [] + const volumes = (result.indicators.quote || [])[0].volume || [] - newObject.Data = timestamps.map((timestamp, index) => [timestamp * 1000, quotes[index]]) + newObject.Data = [] + newObject.VolumeData = [] + + timestamps.forEach((timestamp, index) => { + newObject.Data.push([timestamp * 1000, quotes[index]]) + newObject.VolumeData.push([timestamp * 1000, volumes[index]]) + }) if (result.meta && result.meta.tradingPeriods) { // there can be multiple tradingPeriods and multiple entries inside a tradingPeriod for each time series entry diff --git a/stocks@infinicode.de/services/dto/quoteSummary.js b/stocks@infinicode.de/services/dto/quoteSummary.js index 36e2b5f..fca54bf 100644 --- a/stocks@infinicode.de/services/dto/quoteSummary.js +++ b/stocks@infinicode.de/services/dto/quoteSummary.js @@ -1,3 +1,9 @@ +const ExtensionUtils = imports.misc.extensionUtils +const Me = ExtensionUtils.getCurrentExtension() + +const { isNullOrUndefined } = Me.imports.helpers.data +const { MARKET_STATES } = Me.imports.services.meta.yahoo + var QuoteSummary = class QuoteSummary { constructor (symbol) { this.Name = null @@ -76,6 +82,14 @@ var createQuoteSummaryFromYahooData = (symbol, quoteData, error) => { if (priceData.postMarketTime) { newObject.PostMarketTimestamp = priceData.postMarketTime * 1000 } + + if(newObject.MarketState === MARKET_STATES.PRE && isNullOrUndefined(newObject.PreMarketPrice)){ + newObject.MarketState = MARKET_STATES.PRE_WITHOUT_DATA + } + + if(newObject.MarketState === MARKET_STATES.POST && isNullOrUndefined(newObject.PostMarketPrice)){ + newObject.MarketState = MARKET_STATES.POST_WITHOUT_DATA + } } if (quoteData.quoteSummary.error && quoteData.quoteSummary.error.description) { diff --git a/stocks@infinicode.de/services/meta/yahoo.js b/stocks@infinicode.de/services/meta/yahoo.js index 9c8816d..03e5ac9 100644 --- a/stocks@infinicode.de/services/meta/yahoo.js +++ b/stocks@infinicode.de/services/meta/yahoo.js @@ -9,19 +9,22 @@ var CHART_RANGES = { MAX: 'max' } +// "optimal" roll up for volume bars ~200 items var INTERVAL_MAPPINGS = { - [CHART_RANGES.INTRADAY]: '1m', - [CHART_RANGES.WEEK]: '15m', - [CHART_RANGES.MONTH]: '1h', - [CHART_RANGES.HALF_YEAR]: '1h', - [CHART_RANGES.YEAR_TO_DATE]: '1h', - [CHART_RANGES.YEAR]: '1d', - [CHART_RANGES.FIVE_YEARS]: '1d', - [CHART_RANGES.MAX]: '1d', + [CHART_RANGES.INTRADAY]: '1m', // 4m roll up volume data + [CHART_RANGES.WEEK]: '5m', // 5m roll up volume data + [CHART_RANGES.MONTH]: '5m', // 4h roll up volume data + [CHART_RANGES.HALF_YEAR]: '1h', // 24h roll up volume data + [CHART_RANGES.YEAR_TO_DATE]: '1h', // 24h roll up volume data + [CHART_RANGES.YEAR]: '1d', // 48h roll up volume data + [CHART_RANGES.FIVE_YEARS]: '1d', // 240h roll up volume data + [CHART_RANGES.MAX]: '1d', // 480h roll up volume data } var MARKET_STATES = { - POST: 'POST', PRE: 'PRE', + PRE_WITHOUT_DATA: 'POST_WITHOUT_DATA', + POST: 'POST', + POST_WITHOUT_DATA: 'POST_WITHOUT_DATA', REGULAR: 'REGULAR', }