diff --git a/csfieldguide/interactives/content/en/interactives.yaml b/csfieldguide/interactives/content/en/interactives.yaml index 02040ae625..1c0afea0fc 100644 --- a/csfieldguide/interactives/content/en/interactives.yaml +++ b/csfieldguide/interactives/content/en/interactives.yaml @@ -52,6 +52,8 @@ confusing-error: name: Confusing Error data-bias: name: Data Bias +data-drop: + name: Data Drop data-visualisation: name: Data Visualisation date-picker: diff --git a/csfieldguide/interactives/content/structure/interactives.yaml b/csfieldguide/interactives/content/structure/interactives.yaml index 5d0759718b..0fd90c88f9 100644 --- a/csfieldguide/interactives/content/structure/interactives.yaml +++ b/csfieldguide/interactives/content/structure/interactives.yaml @@ -126,6 +126,10 @@ data-bias: languages: en: interactives/data-bias.html is_interactive: true +data-drop: + languages: + en: interactives/data-drop.html + is_interactive: true data-visualisation: languages: en: interactives/data-visualisation.html diff --git a/csfieldguide/package.json b/csfieldguide/package.json index 5425b13d36..eefe347007 100644 --- a/csfieldguide/package.json +++ b/csfieldguide/package.json @@ -51,6 +51,7 @@ "csfg-interactive-cmy-mixer": "file:./static/interactives/cmy-mixer/", "csfg-interactive-colour-matcher": "file:./static/interactives/colour-matcher/", "csfg-interactive-data-bias": "file:./static/interactives/data-bias/", + "csfg-interactive-data-drop": "file:./static/interactives/data-drop/", "csfg-interactive-data-visualisation": "file:./static/interactives/data-visualisation/", "csfg-interactive-frequency-anaylsis": "file:./static/interactives/frequency-analysis/", "csfg-interactive-jpeg-compression": "file:./static/interactives/jpeg-compression/", diff --git a/csfieldguide/static/interactives/data-drop/README.md b/csfieldguide/static/interactives/data-drop/README.md new file mode 100644 index 0000000000..3bd1c9dc51 --- /dev/null +++ b/csfieldguide/static/interactives/data-drop/README.md @@ -0,0 +1,27 @@ +# Data Drop interactive + +**Authors:** + +- Riley McCann +- Nishant Chintala +- Nonso Iwu +- Connor Creavin + +The original can be found [here](https://github.com/rmccann01/DataVelocityInteractive). + +**Recreated by:** Alasdair Smith + +This interactive demonstrates the limits of processing a lot of data with a single processor. + +## Assets + +Assets used in the `assets` directory are images from the original game, edited by Alasdair Smith for the new context. + +## Licences + +TODO + +## Known Issues + +- The animation of objects will stop while the webpage is hidden, but new data balls are still created. + When the user returns all the new balls are dropped at once. diff --git a/csfieldguide/static/interactives/data-drop/assets/black-pixel.png b/csfieldguide/static/interactives/data-drop/assets/black-pixel.png new file mode 100644 index 0000000000..892dd14a6f Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/black-pixel.png differ diff --git a/csfieldguide/static/interactives/data-drop/assets/cook.jpg b/csfieldguide/static/interactives/data-drop/assets/cook.jpg new file mode 100644 index 0000000000..9d8411becc Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/cook.jpg differ diff --git a/csfieldguide/static/interactives/data-drop/assets/data-packet-blue.png b/csfieldguide/static/interactives/data-drop/assets/data-packet-blue.png new file mode 100644 index 0000000000..6cd7f28ac9 Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/data-packet-blue.png differ diff --git a/csfieldguide/static/interactives/data-drop/assets/data-packet-green.png b/csfieldguide/static/interactives/data-drop/assets/data-packet-green.png new file mode 100644 index 0000000000..8d6e74bb6d Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/data-packet-green.png differ diff --git a/csfieldguide/static/interactives/data-drop/assets/data-packet-purple.png b/csfieldguide/static/interactives/data-drop/assets/data-packet-purple.png new file mode 100644 index 0000000000..e45722b11b Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/data-packet-purple.png differ diff --git a/csfieldguide/static/interactives/data-drop/assets/data-packet-red.png b/csfieldguide/static/interactives/data-drop/assets/data-packet-red.png new file mode 100644 index 0000000000..85cfaad158 Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/data-packet-red.png differ diff --git a/csfieldguide/static/interactives/data-drop/assets/data-packet-yellow.png b/csfieldguide/static/interactives/data-drop/assets/data-packet-yellow.png new file mode 100644 index 0000000000..3a742c4115 Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/data-packet-yellow.png differ diff --git a/csfieldguide/static/interactives/data-drop/assets/data-packet.png b/csfieldguide/static/interactives/data-drop/assets/data-packet.png new file mode 100644 index 0000000000..7199f4c8ca Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/data-packet.png differ diff --git a/csfieldguide/static/interactives/data-drop/assets/dolphin.jpg b/csfieldguide/static/interactives/data-drop/assets/dolphin.jpg new file mode 100644 index 0000000000..7793d7fe35 Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/dolphin.jpg differ diff --git a/csfieldguide/static/interactives/data-drop/assets/kiwi.jpg b/csfieldguide/static/interactives/data-drop/assets/kiwi.jpg new file mode 100644 index 0000000000..f3b5735271 Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/kiwi.jpg differ diff --git a/csfieldguide/static/interactives/data-drop/assets/penguin.jpg b/csfieldguide/static/interactives/data-drop/assets/penguin.jpg new file mode 100644 index 0000000000..95f0f9140a Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/penguin.jpg differ diff --git a/csfieldguide/static/interactives/data-drop/assets/pixel-cup.png b/csfieldguide/static/interactives/data-drop/assets/pixel-cup.png new file mode 100644 index 0000000000..68cabab870 Binary files /dev/null and b/csfieldguide/static/interactives/data-drop/assets/pixel-cup.png differ diff --git a/csfieldguide/static/interactives/data-drop/js/data-drop.js b/csfieldguide/static/interactives/data-drop/js/data-drop.js new file mode 100644 index 0000000000..eefddd1f97 --- /dev/null +++ b/csfieldguide/static/interactives/data-drop/js/data-drop.js @@ -0,0 +1,28 @@ + +require('phaser'); +//var urlParameters = require('../../../js/third-party/url-parameters.js'); +var GAME = require('./game.js'); + +$(document).ready(function() { + var gameScene = new GAME.GameScene(); + var uiScene = new GAME.UIScene(); + + var config = { + type: Phaser.AUTO, + width: 800, + height: 600, + physics: { + default: 'arcade', + arcade: { + gravity: { y: 0 }, + debug: false + } + }, + backgroundColor: '#000', + parent: 'data-drop-game', + scene: gameScene + } + + var game = new Phaser.Game(config); + game.scene.add('UIScene', uiScene, true); +}); \ No newline at end of file diff --git a/csfieldguide/static/interactives/data-drop/js/game.js b/csfieldguide/static/interactives/data-drop/js/game.js new file mode 100644 index 0000000000..66046a7313 --- /dev/null +++ b/csfieldguide/static/interactives/data-drop/js/game.js @@ -0,0 +1,455 @@ +require('phaser'); + +const BOUNDARY_LEFT = 320; +const BOUNDARY_RIGHT = 790; +const SPACING = (BOUNDARY_RIGHT - BOUNDARY_LEFT) / 5; // For four equi-distant drop points +const MAX_OFFSET = SPACING * 0.8; // For variation in the drop points + +const BALLS_PER_IMG = 10; + +const DROP = [ + BOUNDARY_LEFT + 1 * SPACING, + BOUNDARY_LEFT + 2 * SPACING, + BOUNDARY_LEFT + 3 * SPACING, + BOUNDARY_LEFT + 4 * SPACING +]; + +const IMAGE_SIZE = [1000, 444]; +const SCALES = { + CUP: 0.1, + BALL: 0.025, + IMAGE: 0.3 +}; +const COVER_SIZE = [IMAGE_SIZE[0] * SCALES.IMAGE, Math.ceil(IMAGE_SIZE[1] * SCALES.IMAGE)]; +const SHIFT = Math.ceil(COVER_SIZE[1] / (BALLS_PER_IMG)); + +const YPOS = { + KIWI: 10, + PENGUIN: 153, + COOK: 296, + DOLPHIN: 439 +} + +const COLOURS = [ // In order of the images they reveal. Also used to key dictionaries + 'green', + 'purple', + 'red', + 'blue', +]; + +const FREQ = 2000; // (ms) Time between sets of ball drops +const STEP = 100; // (ms) Time between individual ball drops per set + +const TITLE = gettext("DATA DROP"); +const DESCRIPTION = gettext("Use the arrow keys to move your CPU left and right.\n\n\ +Process the balls of data to reveal images.\n\n\ +How does the volume of data affect your performance?\n\n\n\n\ +Press ENTER to start."); + +/** + * Gameplay element. + */ +class GameScene extends Phaser.Scene { + + constructor() { + super({ key: 'GameScene'}); + } + + /** + * Initialises all required variables, handlers, and relevant global registry values + */ + init() { + var scene = this; + + this.level = 0; + this.registry.set('level', this.level); + + this.ballQueue = []; + + this.proportions = {}; + this.proportions[COLOURS[0]] = [0, null]; // Caught, out of + this.proportions[COLOURS[1]] = [0, null]; + this.proportions[COLOURS[2]] = [0, null]; + this.proportions[COLOURS[3]] = [0, null]; + this.registry.set('proportions', this.proportions); + + this.cursors = this.input.keyboard.createCursorKeys(); + this.enterKey = this.input.keyboard.addKey('ENTER'); + this.enterKey.on('down', function() { scene.runEnterHandler() }); + } + + /** + * Loads all required base images + */ + preload() { + this.load.image('cook', base + 'interactives/data-drop/assets/cook.jpg'); + this.load.image('dolphin', base + 'interactives/data-drop/assets/dolphin.jpg'); + this.load.image('kiwi', base + 'interactives/data-drop/assets/kiwi.jpg'); + this.load.image('penguin', base + 'interactives/data-drop/assets/penguin.jpg'); + this.load.image('data-green', base + 'interactives/data-drop/assets/data-packet-green.png'); + this.load.image('data-blue', base + 'interactives/data-drop/assets/data-packet-blue.png'); + this.load.image('data-purple', base + 'interactives/data-drop/assets/data-packet-purple.png'); + this.load.image('data-red', base + 'interactives/data-drop/assets/data-packet-red.png'); + this.load.image('cup', base + 'interactives/data-drop/assets/pixel-cup.png'); + this.load.image('pixel', base + 'interactives/data-drop/assets/black-pixel.png'); + } + + /** + * Creates the GameScene + */ + create() { + var scene = this; + + this.scenery = {}; + this.scenery[COLOURS[0]] = [ // [image, cover] + this.add.image(10, YPOS.KIWI, 'kiwi').setOrigin(0, 0).setScale(SCALES.IMAGE), + this.add.image(10, YPOS.KIWI, 'pixel').setOrigin(0, 0).setScale(COVER_SIZE[0], COVER_SIZE[1]) + ] + this.scenery[COLOURS[1]] = [ + this.add.image(10, YPOS.PENGUIN, 'penguin').setOrigin(0, 0).setScale(SCALES.IMAGE), + this.add.image(10, YPOS.PENGUIN, 'pixel').setOrigin(0, 0).setScale(COVER_SIZE[0], COVER_SIZE[1]) + ] + this.scenery[COLOURS[2]] = [ + this.add.image(10, YPOS.COOK, 'cook').setOrigin(0, 0).setScale(SCALES.IMAGE), + this.add.image(10, YPOS.COOK, 'pixel').setOrigin(0, 0).setScale(COVER_SIZE[0], COVER_SIZE[1]) + ] + this.scenery[COLOURS[3]] = [ + this.add.image(10, YPOS.DOLPHIN, 'dolphin').setOrigin(0, 0).setScale(SCALES.IMAGE), + this.add.image(10, YPOS.DOLPHIN, 'pixel').setOrigin(0, 0).setScale(COVER_SIZE[0], COVER_SIZE[1]) + ] + + this.cup = this.physics.add.image(555, 550, 'cup').setScale(SCALES.CUP); + + this.cupBoundary = this.physics.add.staticGroup(); + this.cupBoundary.create(BOUNDARY_LEFT, 550, 'pixel'); + this.cupBoundary.create(BOUNDARY_RIGHT, 550, 'pixel'); + this.cupBoundary.create(0, 610, 'pixel').setOrigin(0, 0).setScale(800, 1).refreshBody(); // Floor + + this.physics.add.collider(this.cup, this.cupBoundary); + + this.datas = this.physics.add.group(); + this.physics.add.collider(this.cupBoundary, this.datas, function(bound, data) { scene.dataMiss(data) }); + this.physics.add.overlap(this.cup, this.datas, function(cup, data) { scene.dataHit(data) }); + + this.isPreGame = true; + this.preGame(); + } + + /** + * Keyboard controls for the cup + */ + update() { + if (this.cursors.left.isDown) { + this.cup.setVelocityX(-150); + } else if (this.cursors.right.isDown) { + this.cup.setVelocityX(150); + } else { + this.cup.setVelocityX(0); + } + } + + /** + * Runs an animation of dropping balls behind the start screen + */ + preGame() { + var scene = this; + if (scene.isPreGame) { + setTimeout(function() { scene.preGame() }, 1000); + var offset = getRandomInteger(-1 * MAX_OFFSET, MAX_OFFSET); + var positionX = offset + DROP[getRandomInteger(0, DROP.length - 1)]; + var name = 'data-' + COLOURS[getRandomInteger(0, COLOURS.length - 1)]; + scene.dropBall(positionX, name); + } + } + + nextLevel() { + this.level++; + this.registry.set('level', this.level); + if (this.level <= 4) { + this.resetProportions(); + this.setNullProportions(); + this.resetCovers(); + this.startLevel(); + } + } + + resetProportions() { + this.proportions[COLOURS[0]] = [0, 0]; + this.proportions[COLOURS[1]] = [0, 0]; + this.proportions[COLOURS[2]] = [0, 0]; + this.proportions[COLOURS[3]] = [0, 0]; + } + + setNullProportions() { + switch (this.level) { + case 1: + this.proportions[COLOURS[2]][1] = null; + case 2: + this.proportions[COLOURS[1]][1] = null; + case 3: + this.proportions[COLOURS[3]][1] = null; + default: + break; + } + } + + /** + * + * + * The game logic assumes that level number == number of images to reveal. + * Changing that will be difficult + */ + startLevel() { + this.registry.set('proportions', this.proportions); + var ballTypes = []; + var localBallQueue = []; + switch (this.level) { + case 4: + ballTypes.push(COLOURS[3]); + case 3: + ballTypes.push(COLOURS[1]); + case 2: + ballTypes.push(COLOURS[2]); + case 1: + ballTypes.push(COLOURS[0]); + default: + break; + } + for (var i=0; i < BALLS_PER_IMG; i++) { + for (var x=0; x < ballTypes.length; x++) { + localBallQueue.push('data-' + ballTypes[x]); + } + } + shuffle(localBallQueue); + this.ballQueue = localBallQueue; + this.releaseBalls(0); + } + + releaseBalls(num) { + var scene = this; + if (num < this.ballQueue.length) { + setTimeout(function() { scene.releaseBalls(num + scene.level) }, FREQ); + var delay = this.level > 2 ? STEP : STEP * 2; + var offset = getRandomInteger(-1 * MAX_OFFSET, MAX_OFFSET); + this.releaseBallSet(offset, delay, num, num + scene.level); + } + else { + setTimeout(function() { scene.nextLevel() }, 10000); + } + } + + releaseBallSet(offset, delay, next, end) { + var scene = this; + if (next < end) { + setTimeout(function() { scene.releaseBallSet(offset, delay, next + 1, end) }, delay); + var positionX = offset + DROP[getRandomInteger(0, DROP.length - 1)]; + scene.dropBall(positionX, scene.ballQueue[next]); + } + } + + /** + * Drops a new coloured ball with the given key at the given position + */ + dropBall(xPosition, key) { + this.datas.create(xPosition, -100, key).setScale(SCALES.BALL).setVelocityY(100).setAngularVelocity(-100); + } + + /** + * The given data ball has fallen out of the 'world'; destroys it + */ + dataMiss(data) { + if (!this.isPreGame) { + var colour = data.texture.key.split('data-')[1]; // item 0 is "", 1 is the colour + this.proportions[colour][1]++; + this.registry.set('proportions', this.proportions); + } + data.destroy(); + } + + /** + * The given data ball has hit the cup; deals with it appropriately + */ + dataHit(data) { + if (!this.isPreGame) { + var colour = data.texture.key.split('data-')[1]; // item 0 is "", 1 is the colour + this.shiftCover(colour); + this.caught(colour); + } + data.destroy(); + } + + /** + * Reveals a bit more of the image being covered + */ + shiftCover(colour) { + this.scenery[colour][1].setY(this.scenery[colour][1].y + SHIFT); + } + + /** + * + */ + caught(colour) { + this.proportions[colour][0]++; + this.proportions[colour][1]++; + this.registry.set('proportions', this.proportions); + } + + resetCovers() { + this.scenery[COLOURS[0]][1].setY(YPOS.KIWI); + this.scenery[COLOURS[1]][1].setY(YPOS.PENGUIN); + this.scenery[COLOURS[2]][1].setY(YPOS.COOK); + this.scenery[COLOURS[3]][1].setY(YPOS.DOLPHIN); + } + + runEnterHandler() { + if (!this.isPreGame) { // Should never be true but deal with just in case + return; + } + this.enterKey.enabled = false; + this.isPreGame = false; + this.datas.clear(true, true); // Remove all existing balls + this.nextLevel(); + } + + /** + * Handler function for a registry update. + * If a handler is defined for the given key, apply the set handler for that key. + */ + registryUpdate(parent, key, data) { + if (this.handlers[key]) { + this.handlers[key](this, data); + } + } +} + +/** + * Game UI element. + * Handles all button handling and other UI related tasks. + */ +class UIScene extends Phaser.Scene { + + constructor() { + super({ key: 'UIScene' }); + } + + /** + * Initialises all required variables and handlers + */ + init() { + + this.title; + this.description; + this.levelText; + + this.handlers = { + 'level': this.setLevel, + 'proportions': this.setProportions, + } + + this.registry.events.on('changedata', this.registryUpdate, this); + } + + /** + * Loads all required base images + */ + preload() { + } + + /** + * Builds the UI with all elements + */ + create() { + this.title = this.add.text(400, 10, TITLE, { fontSize: '80px', fill: '#fff' }).setOrigin(0.5, 0); + this.description = this.add.text(10, 100, DESCRIPTION); + this.levelText = this.add.text(555, 200, '', { fontSize: '200px', fill: '#fff' }).setAlpha(0.2).setOrigin(0.5, 0); + + this.proportionsText = {}; + var pos = [10 + (IMAGE_SIZE[0] * SCALES.IMAGE) / 2, YPOS.KIWI + (IMAGE_SIZE[1] * SCALES.IMAGE) / 2]; + this.proportionsText[COLOURS[0]] = this.add.text(pos[0], pos[1], '', { fontSize: '40px', fill: '#fff' }).setAlpha(0.5).setOrigin(0.5, 0.5); + pos[1] = YPOS.PENGUIN + (IMAGE_SIZE[1] * SCALES.IMAGE) / 2; + this.proportionsText[COLOURS[1]] = this.add.text(pos[0], pos[1], '', { fontSize: '40px', fill: '#fff' }).setAlpha(0.5).setOrigin(0.5, 0.5); + pos[1] = YPOS.COOK + (IMAGE_SIZE[1] * SCALES.IMAGE) / 2; + this.proportionsText[COLOURS[2]] = this.add.text(pos[0], pos[1], '', { fontSize: '40px', fill: '#fff' }).setAlpha(0.5).setOrigin(0.5, 0.5); + pos[1] = YPOS.DOLPHIN + (IMAGE_SIZE[1] * SCALES.IMAGE) / 2; + this.proportionsText[COLOURS[3]] = this.add.text(pos[0], pos[1], '', { fontSize: '40px', fill: '#fff' }).setAlpha(0.5).setOrigin(0.5, 0.5); + + this.setLevel(this, 0); + } + + setLevel(scene, levelNumber) { + scene.emptyProportionsText(); + if (levelNumber == 0) { + scene.title.setVisible(true); + scene.description.setVisible(true); + scene.levelText.setText(''); + } else { + scene.title.setVisible(false); + scene.description.setVisible(false); + scene.levelText.setText(levelNumber); + } + } + + setProportions(scene, props) { + if (props[COLOURS[0]][1] != null) { + scene.proportionsText[COLOURS[0]].setText(props[COLOURS[0]][0] + ' / ' + props[COLOURS[0]][1]); + } + if (props[COLOURS[1]][1] != null) { + scene.proportionsText[COLOURS[1]].setText(props[COLOURS[1]][0] + ' / ' + props[COLOURS[1]][1]); + } + if (props[COLOURS[2]][1] != null) { + scene.proportionsText[COLOURS[2]].setText(props[COLOURS[2]][0] + ' / ' + props[COLOURS[2]][1]); + } + if (props[COLOURS[3]][1] != null) { + scene.proportionsText[COLOURS[3]].setText(props[COLOURS[3]][0] + ' / ' + props[COLOURS[3]][1]); + } + } + + emptyProportionsText() { + this.proportionsText[COLOURS[0]].setText(''); + this.proportionsText[COLOURS[1]].setText(''); + this.proportionsText[COLOURS[2]].setText(''); + this.proportionsText[COLOURS[3]].setText(''); + } + + /** + * Handler function for a registry update. + * If a handler is defined for the given key, apply the set handler for that key. + */ + registryUpdate(parent, key, data) { + if (this.handlers[key]) { + this.handlers[key](this, data); + } + } +} + +module.exports = { + GameScene, + UIScene +}; + +/** + * Returns a random integer between min and max inclusive. + * From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random + */ +function getRandomInteger(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Fisher-Yates shuffle function from http://bost.ocks.org/mike/shuffle/ + */ +function shuffle(array) { + var counter = array.length, temp, index; + // While there are elements in the array + while (counter > 0) { + // Pick a random index + index = Math.floor(Math.random() * counter); + // Decrease counter by 1 + counter--; + // And swap the last element with it + temp = array[counter]; + array[counter] = array[index]; + array[index] = temp; + } + return array; +} diff --git a/csfieldguide/static/interactives/data-drop/package.json b/csfieldguide/static/interactives/data-drop/package.json new file mode 100644 index 0000000000..ccb94e51bd --- /dev/null +++ b/csfieldguide/static/interactives/data-drop/package.json @@ -0,0 +1,8 @@ +{ + "name": "csfg-interactive-data-drop", + "version": "1.0.0", + "private": true, + "dependencies": { + "phaser": "3.20.1" + } +} diff --git a/csfieldguide/static/interactives/data-drop/scss/data-drop.scss b/csfieldguide/static/interactives/data-drop/scss/data-drop.scss new file mode 100644 index 0000000000..f9480ba5d4 --- /dev/null +++ b/csfieldguide/static/interactives/data-drop/scss/data-drop.scss @@ -0,0 +1,3 @@ +#data-drop-game { + margin: auto; +} diff --git a/csfieldguide/templates/appendices/contributors.html b/csfieldguide/templates/appendices/contributors.html index 3b07339c46..207caec3ad 100644 --- a/csfieldguide/templates/appendices/contributors.html +++ b/csfieldguide/templates/appendices/contributors.html @@ -150,6 +150,10 @@

{% trans 'Comm
  • mkong001 (Minji Kong)
  • koreymitchell (Korey Mitchell)
  • wolginm (Mark Wolgin)
  • +
  • crnishant (Nishant Chintala)
  • +
  • xSmokeAndMirrorsx (Connor Creavin)
  • +
  • nonsoiwu (Nonso Iwu)
  • +
  • rmccann01 (Riley McCann)
  • {% trans 'Note: If there is an error in the list, please contact Jack Morgan' %}

    diff --git a/csfieldguide/templates/interactives/data-drop.html b/csfieldguide/templates/interactives/data-drop.html new file mode 100644 index 0000000000..9921bc913f --- /dev/null +++ b/csfieldguide/templates/interactives/data-drop.html @@ -0,0 +1,21 @@ +{% extends interactive_mode_template %} + +{% load i18n %} +{% load static %} + +{% block html %} +
    +
    +
    +{% endblock html %} + +{% block css %} + +{% endblock css %} + +{% block js %} + + +{% endblock js %}