diff --git a/src/examples/image_carousel/README.md b/src/examples/image_carousel/README.md new file mode 100644 index 00000000..6b0e6c76 --- /dev/null +++ b/src/examples/image_carousel/README.md @@ -0,0 +1,4 @@ +# Comp Three Image Carousel + +This image carousel component allows for the display of images. These images can be provided either as image URLs which are publicly accessible or as a Base64 string in the database. + diff --git a/src/examples/image_carousel/c3_image_carousel.png b/src/examples/image_carousel/c3_image_carousel.png new file mode 100644 index 00000000..d553f69e Binary files /dev/null and b/src/examples/image_carousel/c3_image_carousel.png differ diff --git a/src/examples/image_carousel/constants.js b/src/examples/image_carousel/constants.js new file mode 100644 index 00000000..f9643082 --- /dev/null +++ b/src/examples/image_carousel/constants.js @@ -0,0 +1,229 @@ +export const CAROUSELCSS = ` +.carousel .control-arrow, .carousel.carousel-slider .control-arrow { + -webkit-transition: all 0.25s ease-in; + -moz-transition: all 0.25s ease-in; + -ms-transition: all 0.25s ease-in; + -o-transition: all 0.25s ease-in; + transition: all 0.25s ease-in; + opacity: 0.4; + filter: alpha(opacity=40); + position: absolute; + z-index: 2; + top: 20px; + background: none; + border: 0; + font-size: 32px; + cursor: pointer; } + .carousel .control-arrow:hover { + opacity: 1; + filter: alpha(opacity=100); } + .carousel .control-arrow:before, .carousel.carousel-slider .control-arrow:before { + margin: 0 5px; + display: inline-block; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + content: ''; } + .carousel .control-disabled.control-arrow { + opacity: 0; + filter: alpha(opacity=0); + cursor: inherit; + display: none; } + .carousel .control-prev.control-arrow { + left: 0; } + .carousel .control-prev.control-arrow:before { + border-right: 8px solid #fff; } + .carousel .control-next.control-arrow { + right: 0; } + .carousel .control-next.control-arrow:before { + border-left: 8px solid #fff; } + +.carousel { + position: relative; + width: 100%; } + .carousel * { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; } + .carousel img { + width: 100%; + display: inline-block; + pointer-events: none; } + .carousel .carousel { + position: relative; } + .carousel .control-arrow { + outline: 0; + border: 0; + background: none; + top: 50%; + margin-top: -13px; + font-size: 18px; } + .carousel .thumbs-wrapper { + margin: 20px; + overflow: hidden; } + .carousel .thumbs { + -webkit-transition: all 0.15s ease-in; + -moz-transition: all 0.15s ease-in; + -ms-transition: all 0.15s ease-in; + -o-transition: all 0.15s ease-in; + transition: all 0.15s ease-in; + -webkit-transform: translate3d(0, 0, 0); + -moz-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + -o-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + position: relative; + list-style: none; + white-space: nowrap; } + .carousel .thumb { + -webkit-transition: border 0.15s ease-in; + -moz-transition: border 0.15s ease-in; + -ms-transition: border 0.15s ease-in; + -o-transition: border 0.15s ease-in; + transition: border 0.15s ease-in; + display: inline-block; + width: 80px; + margin-right: 6px; + white-space: nowrap; + overflow: hidden; + border: 3px solid #fff; + padding: 2px; } + .carousel .thumb:focus { + border: 3px solid #ccc; + outline: none; } + .carousel .thumb.selected, .carousel .thumb:hover { + border: 3px solid #333; } + .carousel .thumb img { + vertical-align: top; } + .carousel.carousel-slider { + position: relative; + margin: 0; + overflow: hidden; } + .carousel.carousel-slider .control-arrow { + top: 0; + color: #fff; + font-size: 26px; + bottom: 0; + margin-top: 0; + padding: 5px; } + .carousel.carousel-slider .control-arrow:hover { + background: rgba(0, 0, 0, 0.2); } + .carousel .slider-wrapper { + overflow: hidden; + margin: auto; + width: 100%; + -webkit-transition: height 0.15s ease-in; + -moz-transition: height 0.15s ease-in; + -ms-transition: height 0.15s ease-in; + -o-transition: height 0.15s ease-in; + transition: height 0.15s ease-in; } + .carousel .slider-wrapper.axis-horizontal .slider { + -ms-box-orient: horizontal; + display: -webkit-box; + display: -moz-box; + display: -ms-flexbox; + display: -moz-flex; + display: -webkit-flex; + display: flex; } + .carousel .slider-wrapper.axis-horizontal .slider .slide { + flex-direction: column; + flex-flow: column; } + .carousel .slider-wrapper.axis-vertical { + -ms-box-orient: horizontal; + display: -webkit-box; + display: -moz-box; + display: -ms-flexbox; + display: -moz-flex; + display: -webkit-flex; + display: flex; } + .carousel .slider-wrapper.axis-vertical .slider { + -webkit-flex-direction: column; + flex-direction: column; } + .carousel .slider { + margin: 0; + padding: 0; + position: relative; + list-style: none; + width: 100%; } + .carousel .slider.animated { + -webkit-transition: all 0.35s ease-in-out; + -moz-transition: all 0.35s ease-in-out; + -ms-transition: all 0.35s ease-in-out; + -o-transition: all 0.35s ease-in-out; + transition: all 0.35s ease-in-out; } + .carousel .slide { + min-width: 100%; + margin: 0; + position: relative; + text-align: center; + background: #000; } + .carousel .slide img { + width: 100%; + vertical-align: top; + border: 0; } + .carousel .slide iframe { + display: inline-block; + width: calc(100% - 80px); + margin: 0 40px 40px; + border: 0; } + .carousel .slide .legend { + -webkit-transition: all 0.5s ease-in-out; + -moz-transition: all 0.5s ease-in-out; + -ms-transition: all 0.5s ease-in-out; + -o-transition: all 0.5s ease-in-out; + transition: all 0.5s ease-in-out; + position: absolute; + bottom: 40px; + left: 50%; + margin-left: -45%; + width: 90%; + border-radius: 10px; + background: #000; + color: #fff; + padding: 10px; + font-size: 12px; + text-align: center; + opacity: 0.25; + -webkit-transition: opacity 0.35s ease-in-out; + -moz-transition: opacity 0.35s ease-in-out; + -ms-transition: opacity 0.35s ease-in-out; + -o-transition: opacity 0.35s ease-in-out; + transition: opacity 0.35s ease-in-out; } + .carousel .control-dots { + position: absolute; + bottom: 0; + margin: 10px 0; + text-align: center; + width: 100%; } + @media (min-width: 960px) { + .carousel .control-dots { + bottom: 0; } } + .carousel .control-dots .dot { + -webkit-transition: opacity 0.25s ease-in; + -moz-transition: opacity 0.25s ease-in; + -ms-transition: opacity 0.25s ease-in; + -o-transition: opacity 0.25s ease-in; + transition: opacity 0.25s ease-in; + opacity: 0.3; + filter: alpha(opacity=30); + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.9); + background: #fff; + border-radius: 50%; + width: 8px; + height: 8px; + cursor: pointer; + display: inline-block; + margin: 0 8px; } + .carousel .control-dots .dot.selected, .carousel .control-dots .dot:hover { + opacity: 1; + filter: alpha(opacity=100); } + .carousel .carousel-status { + position: absolute; + top: 0; + right: 0; + padding: 5px; + font-size: 10px; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.9); + color: #fff; } + .carousel:hover .slide .legend { + opacity: 1; } +`; \ No newline at end of file diff --git a/src/examples/image_carousel/imageViewer.js b/src/examples/image_carousel/imageViewer.js new file mode 100644 index 00000000..3143fe00 --- /dev/null +++ b/src/examples/image_carousel/imageViewer.js @@ -0,0 +1,131 @@ +import React from 'react'; +import { Carousel } from 'react-responsive-carousel'; + +const URLREGEX = new RegExp("((http|https)(:\/\/))?([a-zA-Z0-9]+[.]{1}){2}[a-zA-Z0-9]+(\/{1}[a-zA-Z0-9]+)*\/?", "igm"); +// Regular expression to check formal correctness of base64 encoded strings +// https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/atob +const b64re = /^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/; + +const DOTS_THRESHOLD = 15; + +const isUrlCheck = (strToCheck) => { + return URLREGEX.test(strToCheck); +} + +const isBase64StringCheck = (strToCheck) => { + if (strToCheck && strToCheck.length > 150 && b64re.test(strToCheck)) { + try { + return btoa(atob(strToCheck)) === strToCheck; + } catch (err) { + return false; + } + } + return false; +} + +// NOTE: this method loops over column key names without looking at column order +const findImageCol = (stateData) => { + let tmpImageColData = { + type: { + url: false, + base64: false + }, + name: null + } + + for (let row of stateData) { + for (let colName of Object.keys(row)) { + if (isUrlCheck(row[colName].value)) { + tmpImageColData.type.url = true; + tmpImageColData.type.base64 = false; // set this in case we found b64 before finding a url + tmpImageColData.name = colName; + return tmpImageColData; + } else if (isBase64StringCheck(row[colName].value)) { + tmpImageColData.type.base64 = true; + tmpImageColData.name = colName; + } + } + // stop looping rows if we found a valid image column + if (tmpImageColData.name) { + return tmpImageColData; + } + } + // resets this.state.imageColData to falsy if no valid image col was found + return tmpImageColData; +} + +// Create (or import) our react component +export default class ImageViewer extends React.Component { + constructor () { + super(); + + // Set initial state to a loading or no data message, initialize imageColData + this.state = { + data: null, + queryResponse: null, + imageColData: { // flatten + type: { + url: false, + base64: false + }, + name: null + } + }; + } + + // component mount/recv props, should component update - lifecycle method + // if there is new data, call again to find column ... + + // render our data + render() { + if (!this.state.data) { + return ( +
No Image Data Found
+ ); + } + + if (!this.state.imageColData.name) { + this.state.imageColData = findImageCol(this.state.data); + } + + // stop if no valid image data column found + if (!this.state.imageColData.name) { + return ( +
Please select at least one field with an image url or a base64 encoded image.
+ ); + } + + // check first row for the image column, if it is not present find the new valid image column + // Rerun the image column check to make sure there is still a valid column, this needs to be refreshed when the + // explore is changed in looker. + if (typeof this.state.data[0][this.state.imageColData.name] === 'undefined') { + this.state.imageColData = findImageCol(this.state.data); + } + + let tableRows = this.state.data.map((row, idx) => { + let val = row[this.state.imageColData.name].value; // image url or base64 string + + if (this.state.imageColData.type.base64) { + val = `data:image/image;base64,${val}`; + } + + return ( +
+ +
+ ); + }); + + // display image index linked dots in bottom of carousel, dots will stack into additional rows they overrun the carousel width + let showIndicatorsBool = true; + if (tableRows.length > DOTS_THRESHOLD) { + showIndicatorsBool = false; + } + + return ( + + {tableRows} + + ); + } +} diff --git a/src/examples/image_carousel/image_carousel.js b/src/examples/image_carousel/image_carousel.js new file mode 100644 index 00000000..a455bf46 --- /dev/null +++ b/src/examples/image_carousel/image_carousel.js @@ -0,0 +1,61 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import ImageViewer from './imageViewer' +import { CAROUSELCSS } from './constants'; + + +looker.plugins.visualizations.add({ + // Id and Label are legacy properties that no longer have any function besides documenting + // what the visualization used to have. The properties are now set via the manifest + // form within the admin/visualizations page of Looker + id: "c3_image_carousel", + label: "C3 Image Carousel", + // Set up the initial state of the visualization + create: function(element, config) { + // Insert a `; + + // Create a container element to let us center the text. + let container = element.appendChild(document.createElement("div")); + container.className = "c3-image_carousel"; + + // Create an element to contain the text. + this._textElement = container.appendChild(document.createElement("div")); + + // Render to the target element + this.chart = ReactDOM.render( + , + this._textElement + ); + }, + // Render in response to the data or settings changing + updateAsync: function(data, element, config, queryResponse, details, done) { + + // Clear any errors from previous updates + this.clearErrors(); + + // Throw some errors and exit if the shape of the data isn't what this chart needs + if (queryResponse.fields.dimensions.length == 0) { + this.addError({title: "No Dimensions", message: "This chart requires dimensions."}); + return; + } + + // Finally update the state with our new data + this.chart.setState({data, queryResponse}) + + // We are done rendering! Let Looker know. + done() + } +}); diff --git a/webpack.config.js b/webpack.config.js index 4b95c4e6..529606d4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,7 +15,8 @@ var webpackConfig = { collapsible_tree: './src/examples/collapsible_tree/collapsible_tree.ts', chord: './src/examples/chord/chord.ts', treemap: './src/examples/treemap/treemap.ts', - subtotal: './src/examples/subtotal/subtotal.ts' + subtotal: './src/examples/subtotal/subtotal.ts', + image_carousel: './src/examples/image_carousel/image_carousel.js' }, output: { filename: "[name].js",