ExtJS - Stacked Bar Chart Conditional Coloring - javascript

EXTJS 4 - I am trying to customize the renderer function for the 'series' in StackedBarChart. I want to conditionally color the bars.
renderer: function(sprite, record, curAttr, index, store) {
return Ext.apply(curAttr, {
fill: color
});
return curAttr;
},
My Question is, how to find out which element its currently rendering. I want to give white color to the first element of each record in my data store/series.
Thank you.

I found a way to detect exactly which element is currently being rendered. First, you'll need the following override, which addresses several issues with the renderer parameters. It shouldn't affect the normal bar charts, but I haven't tested them.
Ext.override(Ext.chart.series.Bar,{
drawSeries: function() {
var me = this,
chart = me.chart,
store = chart.getChartStore(),
surface = chart.surface,
animate = chart.animate,
stacked = me.stacked,
column = me.column,
enableShadows = chart.shadow,
shadowGroups = me.shadowGroups,
shadowGroupsLn = shadowGroups.length,
group = me.group,
seriesStyle = me.seriesStyle,
items, ln, i, j, baseAttrs, sprite, rendererAttributes, shadowIndex, shadowGroup,
bounds, endSeriesStyle, barAttr, attrs, anim;
// ---- start edit ----
var currentCol, currentStoreIndex;
// ---- end edit ----
if (!store || !store.getCount()) {
return;
}
//fill colors are taken from the colors array.
delete seriesStyle.fill;
endSeriesStyle = Ext.apply(seriesStyle, this.style);
me.unHighlightItem();
me.cleanHighlights();
me.getPaths();
bounds = me.bounds;
items = me.items;
baseAttrs = column ? {
y: bounds.zero,
height: 0
} : {
x: bounds.zero,
width: 0
};
ln = items.length;
// Create new or reuse sprites and animate/display
for (i = 0; i < ln; i++) {
sprite = group.getAt(i);
barAttr = items[i].attr;
if (enableShadows) {
items[i].shadows = me.renderShadows(i, barAttr, baseAttrs, bounds);
}
// ---- start edit ----
if (stacked && items[i].storeItem.index != currentStoreIndex) {
//console.log("i: %o, barsLen: %o, j: %o, items[i]: %o",i,bounds.barsLen,i / bounds.barsLen,items[i]);
currentStoreIndex = items[i].storeItem.index;
currentCol = 0;
}
else {
++currentCol;
}
// ---- end edit ----
// Create a new sprite if needed (no height)
if (!sprite) {
attrs = Ext.apply({}, baseAttrs, barAttr);
attrs = Ext.apply(attrs, endSeriesStyle || {});
sprite = surface.add(Ext.apply({}, {
type: 'rect',
group: group
}, attrs));
}
if (animate) {
// ---- start edit ----
rendererAttributes = me.renderer(sprite, items[i].storeItem, barAttr, (stacked? currentStoreIndex : i), store, (stacked? currentCol : undefined));
// ---- end edit ----
sprite._to = rendererAttributes;
anim = me.onAnimate(sprite, { to: Ext.apply(rendererAttributes, endSeriesStyle) });
if (enableShadows && stacked && (i % bounds.barsLen === 0)) {
j = i / bounds.barsLen;
for (shadowIndex = 0; shadowIndex < shadowGroupsLn; shadowIndex++) {
anim.on('afteranimate', function() {
this.show(true);
}, shadowGroups[shadowIndex].getAt(j));
}
}
}
else {
// ---- start edit ----
rendererAttributes = me.renderer(sprite, items[i].storeItem, Ext.apply(barAttr, { hidden: false }), (stacked? currentStoreIndex : i), store, (stacked? currentCol : undefined));
// ---- end edit ----
sprite.setAttributes(Ext.apply(rendererAttributes, endSeriesStyle), true);
}
items[i].sprite = sprite;
}
// Hide unused sprites
ln = group.getCount();
for (j = i; j < ln; j++) {
group.getAt(j).hide(true);
}
// Hide unused shadows
if (enableShadows) {
for (shadowIndex = 0; shadowIndex < shadowGroupsLn; shadowIndex++) {
shadowGroup = shadowGroups[shadowIndex];
ln = shadowGroup.getCount();
for (j = i; j < ln; j++) {
shadowGroup.getAt(j).hide(true);
}
}
}
me.renderLabels();
}
});
Here's the change list for renderer():
The second parameter is now mapped to the correct store item
The fourth parameter is now the store index number instead of the internal item number (worthless otherwise IMO)
Added a sixth parameter that tells the current segment index within the record, not counting other properties in the record that don't belong to the axis.
Example: for a record that looks like {name: 'metric': segment1: 12, segment2: 22}, the index for segment1 will be 0 instead of 1, because the first item in the record does not belong to its axis (it's the category name)
So, to answer your question, now you can use the renderer like so:
renderer: function(sprite, record, attr, storeIndex, store, col) {
// set the color to white for the first item in every record
return (col == 0)? Ext.apply(attr, { fill: '#fff' }) : attr;
}
If you want to set the color for a named item, you may also do it like this:
// let's say that every record looks like this:
// {name: 'metric one', user1: 23, user2: 50, user3: 10}
renderer: function(sprite, record, attr, storeIndex, store, col) {
// retrieve the segment property name from the record using its numeric index.
// remember that 'col' doesn't take into account other fields that don't
// belong to the axis, so in this case we have to add 1 to the index
var uid = Ext.Object.getAt(record.data, col+1)[0];
// set the color to red for 'user2'
return (uid == 'user2')? Ext.apply(attr, { fill: '#f00' }) : attr;
}
For that last one, you'll need this function, which allows you to retrieve a property from an object using a numeric index:
/**
* Returns the key and its value at the idx position
* #return {Array} Array containing [key, value] or null
*/
Ext.Object.getAt = function(obj, idx) {
var keys = Ext.Object.getKeys(obj);
if (idx < keys.length) {
return [keys[idx],obj[keys[idx]]];
}
return null;
}

The index tells you which element is rendering. I've noticed however that in some situations renderer() is called 3 times before the elements start getting processed. I asked in the Sencha forums about this but to no avail.

Related

Connect 4 check for win isn't working - how do I fix?

EDIT: The instructions were really confusing for me.
"Step Seven: findSpotForCol and endGame
Right now, the game drops always drops a piece to the top of the column, even if a piece is already there. Fix this function so that it finds the lowest empty spot in the game board and returns the y coordinate (or null if the column is filled)."
This makes me think that findSpotForCol is supposed to put where each piece goes, however step 5 says:
"While not everything will work, you should now be able to click on a column and see a piece appear at the very bottom of that column."
So the piece should already be at the bottom on step 5, not waiting til step 7?
*****
I am doing an assignment creating a connect 4 game. There was some code already, and instructions on how to complete the rest. I, uh, don't think I followed the instructions to a "t" (as in certain code should go inside a certain function), but it all works as intended (mostly).
Every click is a different turn(changing the piece colors). Pieces stack how they're supposed to. If the board is completely full (as in tie), the game ends. Basically everything works in the game except for "ending the game" when 4 of the same pieces match.
The code for checking the win had already been written by my instructor. Is anyone able to check my code and advise how the check for win should be written for the code I have? I've spent quite a few hours on this, and rather not start over to write code that works for the check-for-win function pre-set by my instructor.
HTML
<!doctype html>
<head>
<title>Connect 4</title>
<link href="connect4.css" rel="stylesheet">
</head>
<body>
<div id="game">
<table id="board"></table>
</div>
<script src="connect4.js"></script>
</body>
</html>
CSS
/* game board table */
#board td {
width: 50px;
height: 50px;
border: solid 1px #666;
}
/* pieces are div within game table cells: draw as colored circles */
.piece {
/* TODO: make into circles */
margin: 5px;
width: 80%;
height: 80%;
border-radius: 50%;
}
/* TODO: make pieces red/blue, depending on player 1/2 piece */
.plyr1 {
background-color: blue;
}
.plyr2 {
background-color: red;
}
/* column-top is table row of clickable areas for each column */
#column-top td {
border: dashed 1px lightgray;
}
#column-top td:hover {
background-color: gold;
}
JS
/** Connect Four
*
* Player 1 and 2 alternate turns. On each turn, a piece is dropped down a
* column until a player gets four-in-a-row (horiz, vert, or diag) or until
* board fills (tie)
*/
const WIDTH = 7;
const HEIGHT = 6;
let currPlayer = 1; // active player: 1 or 2
const board = []; // array of rows, each row is array of cells (board[y][x])
/** makeBoard: create in-JS board structure:
* board = array of rows, each row is array of cells (board[y][x])
*/
function makeBoard() {
let board2 = [];
for (let i = 0; i < HEIGHT; i++) {
board2.push(null);
for (let j = 0; j < WIDTH; j++) {
board[i] = board2;
}
}
}
/** makeHtmlBoard: make HTML table and row of column tops. */
function makeHtmlBoard() {
const htmlBoard = document.querySelector('#board'); //selecting the board
const top = document.createElement('tr'); //creating a table row element
top.setAttribute('id', 'column-top'); //setting id of tr just created
top.addEventListener('click', handleClick); // adding an event listener that listens for handleClick( function)
for (let x = 0; x < WIDTH; x++) {
const headCell = document.createElement('td'); //creating a table data element equal to WIDTH
headCell.setAttribute('id', x); //setting id of td just created to x
top.append(headCell); //displaying headingCell right under where top is displayed (nesting the td inside of the tr)
}
htmlBoard.append(top); //displaying top right under where htmlBoard is displayed
for (let y = 0; y < HEIGHT; y++) {
const row = document.createElement('tr'); //creating a table row element * HEIGHT
for (let x = 0; x < WIDTH; x++) {
const cell = document.createElement('td'); //creating a table data element equal to WIDTH
cell.setAttribute('id', `${y}-${x}`); //setting id of td just created
row.append(cell); //displaying cell right under where row is displayed (nesting each td inside of each tr)
}
htmlBoard.append(row); //displaying row right under where htmlBoard is displayed
}
}
/** findSpotForCol: given column x, return top empty y (null if filled) */
function findSpotForCol(x) {
// TODO: write the real version of this, rather than always returning 0
return 0;
}
/** placeInTable: update DOM to place piece into HTML table of board */
function placeInTable(y, x) {
// TODO: make a div and insert into correct table cell
const div = document.createElement('div');
div.classList.add('piece');
const top = document.querySelector(`[id='0-${x}']`);
if (currPlayer === 1 && top.innerHTML === '') {
div.classList.add('plyr1');
currPlayer = 2;
} else if (currPlayer === 2 && top.innerHTML === '') {
div.classList.add('plyr2');
currPlayer = 1;
}
let arrHeight = [];
for (let i = 0; i < HEIGHT; i++) {
arrHeight.push(i);
}
for (i of arrHeight.reverse()) {
if (document.getElementById(`${i}-${x}`).innerHTML === '') {
const selected = document.getElementById(`${i}-${x}`);
const top = document.getElementById(`6 -${x}`);
return selected.append(div);
}
}
}
/** endGame: announce game end */
function endGame(msg) {
// TODO: pop up alert message
alert('Test');
}
/** handleClick: handle click of column top to play piece */
function handleClick(evt) {
// get x from ID of clicked cell
let x = +evt.target.id;
// get next spot in column (if none, ignore click)
let y = findSpotForCol(x);
if (y === null) {
return;
}
// place piece in board and add to HTML table
// TODO: add line to update in-memory board
placeInTable(y, x);
// check for win
if (checkForWin()) {
return endGame(`Player ${currPlayer} won!`);
}
// check for tie
// TODO: check if all cells in board are filled; if so call, call endGame
const tie = document.querySelectorAll('td');
const tieArr = [ ...tie ];
tieArr.reverse();
for (let i = 0; i < WIDTH; i++) {
tieArr.pop();
}
let tie42 = tieArr.filter((v) => {
return v.innerHTML !== '';
});
if (tie42.length === 42) {
setTimeout(() => {
endGame();
}, 1);
}
}
/** checkForWin: check board cell-by-cell for "does a win start here?" */
function checkForWin() {
function _win(cells) {
// Check four cells to see if they're all color of current player
// - cells: list of four (y, x) cells
// - returns true if all are legal coordinates & all match currPlayer
return cells.every(([ y, x ]) => y >= 0 && y < HEIGHT && x >= 0 && x < WIDTH && board[y][x] === currPlayer);
}
// TODO: read and understand this code. Add comments to help you.
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
let horiz = [ [ y, x ], [ y, x + 1 ], [ y, x + 2 ], [ y, x + 3 ] ];
let vert = [ [ y, x ], [ y + 1, x ], [ y + 2, x ], [ y + 3, x ] ];
let diagDR = [ [ y, x ], [ y + 1, x + 1 ], [ y + 2, x + 2 ], [ y + 3, x + 3 ] ];
let diagDL = [ [ y, x ], [ y + 1, x - 1 ], [ y + 2, x - 2 ], [ y + 3, x - 3 ] ];
if (_win(horiz) || _win(vert) || _win(diagDR) || _win(diagDL)) {
return true;
}
}
}
}
makeBoard();
makeHtmlBoard();
There are a few essential things you did not code:
// TODO: add line to update in-memory board
// TODO: write the real version of this, rather than always returning 0
Without those implementations you cannot expect the 4-in-a-row detection to work.
But let's take the issues one by one, starting at the top:
(1) The way you create the board is wrong: it only creates one board2 array, and so all the entries of board are referencing the same array. Whatever happens to it will be the same whether you look at it via board[0], board[1], or whichever entry... Here is how you could do it correctly:
function makeBoard() {
for (let i = 0; i < HEIGHT; i++) {
board[i] = Array(WIDTH).fill(null); // create a new array each time!
}
}
(2) The function findSpotForCol always returns 0, which of course is wrong. It is a pity you did not attempt to complete this function. The purpose of such assignments is that you work on them, and learn as you try.
It could look like this:
function findSpotForCol(x) {
for (let y = 0; y < HEIGHT; y++) {
if (board[y][x] === null) return y;
}
return null;
}
(3) placeInTable should not change currPlayer. It is not its job to do so. Changing it here will give it the wrong value at the time you call checkForWin. Instead you should modify currPlayer as the last thing you do in handleClick, and it can be simply this line:
currPlayer = 3 - currPlayer;
(3b) placeInTable is overly complicated. Although it works fine, I would write it like below, making use of the rows and cells methods; no need for HTML id attributes, except for the id of the table element.
function placeInTable(y, x) {
const div = document.createElement('div');
div.classList.add('piece', 'plyr' + currPlayer);
document.querySelector("#board").rows[HEIGHT - y].cells[x].append(div);
}
(4) The endGame shows "Test", which is not its purpose. Again, you should have updated that code. It should alert this:
alert(msg);
(5) In handleClick you did not do anything to modify the board. You should add this line just before the call to placeInTable:
board[y][x] = currPlayer;
(6) The check for a tie should not be based on what is in the document, but on what is in memory. A move counter would make it easier, but let's just do it by scanning board. You should of course pass an argument to endGame:
if (board.every(row => row.every(Boolean))) {
return endGame("It's a draw!");
}
Note the use of Boolean here. It is the callback function given to every, and will return false when it is given null, true when it is given 1 or 2.
With these adaptations it should work.
Just judging from a quick glance on your code: There is an in-memory table called board, which the check for a win is using. However, your placeInTable implementation does not update this table, but directly inserts DOM-elements.
What I would advise you to do instead, is to sepearte the model (the state of the game) from the visualization. When you get the click-event, you update the in-memory table, then you visualize the entire table, e.g. by setting the appropriate css-classes in the already created html-table.
Then you check for a win.
Also, the check for winning compares against a variable called test, which does not seem to be defined anywhere.

Can someone explain how to properly write this Javascript program?

I'm looking for someone to show me how this task should be done and where I went wrong. The instructions are as follows:
"Write your code for this exercise in this file beneath this long comment.
Please be certain to use only syntax and techniques covered in the assigned
freeCodeCamp courses.
(The material referenced here was the 110 lessons of FreeCodeCamp's basic Javascript, exploring Var and Let keywords and the 26 Object oriented programming lessons)
Write a function named createMovie that expects to receive three arguments:
title, duration, and quote. This function should return an object. The object
it returns should have properties that are also named title, duration, and
quote. The values assigned to these properties should be the values that are
passed to the function. Additionally, the object that createMovie returns
should have two methods:
isLongerThan - a function that accepts one movie object as a parameter and
returns true if the movie is longer than the one that is passed to it as
an argument and false otherwise.
logQuote - a function that logs the value of the movie object's quote
property to the console.
Create a variable named movies and assign to it an array. This array should
contain six objects that are created by calling the createMovie function. The
values you should pass to the createMovie function to create these objects are:
title | duration | line
----------------------------------------------------------------------------
Star Wars | 121 | If there's a bright center to the universe,
| | you're on the planet that it's farthest from.
| |
Pulp Fiction | 154 | Do you know what they call a Quarter Pounder
| | with Cheese in France?
| |
Dirty Dancing | 100 | Nobody puts Baby in a corner.
| |
Forrest Gump | 142 | Life is like a box of chocolates.
| |
The Wizard of Oz | 101 | Lions and tigers and bears, oh my!
| |
Cabaret | 124 | Life is a cabaret, old chum, so come to the
| | cabaret.
Write the following two functions, both of which use the movies array to
determine what to return.
getMovieByTitle - this function expects a string as a parameter and
returns the object in the movies array whose title property is equal to
the string that is passed to it (if there is one).
getAverageDuration - this function returns the average duration of all the
movies in the array.
You can test your code by opening index.html in Chrome and using the console
(see http://jsforcats.com/ for instructions on using the console). After you
correct any errors you see when you open the console, you can run commands such
as those below and verify the output.
var starWars = getMovieByTitle('Star Wars');
var pulpFiction = getMovieByTitle('Pulp Fiction');
pulpFiction.isLongerThan(starWars);
pulpFiction.logQuote();
getAverageDuration();
*/"
so the code I wrote was formed from some pseudo-code that was as best as I could get towards the answer.
I'm completely new to this and I've definitely bitten off more than I can chew. Any help would be appreciated.
Here is as far as I got:
var Movies = [];
function CreateMovie (id, title, duration, quote) {
let Films = {
title: title,
duration: duration,
quote: quote,
isLongerThan: function (Movies) {
for (var x = 0; x < Movies.length; x++) {
for (var y = 0; y < Movies[x].length; y++) {
if (This.duration > Movies[x][y].duration) {
return true;
} else {
return false;
}
}
}
},
logQuote: function (title){
for (var x = 0; x < Movies.length; x++) {
for (var y = 0; y < Movies[x].length; y++){
if (Movies[x][y].hasOwnProperty(title)){
console.log(Movies[x][y].quote)
}
}
}
}
};
Movies.push(Films);
return Films;
};
function getMovieByTitle (title) {
for (var x = 0; x < Movies.length; x++) {
for (var y = 0; y < Movies[x].length; y++) {
if (title === Movies[x][y].title) {
return Movies[x];
} else {
return undefined;
}
};
function getAverageDuration () {
var totalMovies = [];
for (var i = 0; i < Movies.length; i++) {
totalMovies.push[i];
}
var durationTotal = 0;
for (var x = 0; x < Movies.length; x++) {
durationTotal += (Movies[x][2]) ;
}
var totalAvg = (durationTotal / totalMovies.length);
return totalAvg;
};
I appreciate that this might all be completely garbage code, but I'm hoping if someone can show me the light, it might inspire me to keep progressing with coding instead of giving up and continue working in a bar forever
Sorry to hear about your setback. Here's the code, let me know if you have questions:
class Movie {
constructor(title, duration, quote) {
this.title = title;
this.duration = duration;
this.quote = quote;
}
isLongerThan(other) {
return this.duration > other.duration;
}
logQuote() {
console.log(this.quote);
}
}
function createMovie(title, duration, quote) {
return new Movie(title, duration, quote);
}
function getMovieByTitle(movies, title) {
for (let m of movies)
if (m.title === title)
return m;
}
function getAverageDuration(movies) {
let total = 0;
for (let m of movies)
total += m.duration;
return total / movies.length;
}
This is the simplest version without using any class, and functions that you are yet to familiarize. I have written a simple solution with loops. You can write the same thing using class and map functions as given in other answers.
let movies = [];
/**
* Creates a new movie object and adds the object to
* the movies array.
*
* Returns the newly created movies object.
*/
function createMovie(title, duration, quote) {
let movie = {
title: title,
duration: duration,
quote: quote,
isLongerThan: function (other_movie) {
return this.duration > other_movie.duration;
},
logQuote: function () {
console.log(this.quote);
}
}
movies.push(movie);
return movie;
}
/**
* Searches the movies array for matching title and returns
* the movie object if a match is found. Returns "undefined"
* if no atch is found.
*
* #param string title
*/
function getMovieByTitle(title) {
for (let i = 0; i < movies.length; i++) {
let movie = movies[i];
if (movie.title === title) {
return movie;
}
}
}
/**
* Gets the average duration of all the movies using a simple
* for loop.
*/
function getAverageDuration() {
let total_duration = 0;
let average_duration = 0;
if (movies.length > 0) {
// Iterate through the movies, if movies array
// is not empty. If we don't do this check, the average
// duration could result in an NaN result (division by 0).
for (let i = 0; i < movies.length; i++) {
let movie = movies[i];
total_duration += isNan(movie.duration) ? 0 : movie.duration;
}
// Rounds the average to two decimal places.
average_duration = (total_duration / movies.length).toFixed(2);
}
return average_duration;
}
It sucks to be stuck on a piece of code. We've all been there.
This is how I would execute your assignment.
And just like #georg. If you have any questions, please let us know.
class Movie {
constructor(title, duration, quote) {
this.title = title;
this.duration = duration;
this.quote = quote;
}
isLongerThan(movie) {
return this.movie.duration > movie.duration;
}
logQuote() {
console.log(this.quote);
}
}
const movies = [];
function createMovie(title, duration, quote) {
let movie = new Movie(title, duration, quote);
movies.push(movie);
}
function getMovieByTitle(title) {
return movies.find(movie => movie.title === title);
}
function getAverageDuration() {
return movies.reduce(accumulator, movie => {
return accumulator + movie.duration;
}, 0) / movies.length;
}
It sux spending money on education and not getting it approved :/. Hope you don't give up!
I tried to replicate your enviroment. It stands in your task you should not use other stuff you haven't learned yet. I used a more modern for-loop than you...
var movies = [];
// Task 1:
function createMovie(title, duration, quote) {
// Something missing
if (!title || !duration || !quote) {
console.error('Missing parameter.');
return null;
}
// Convert type
if (typeof duration === 'string') {
duration = Number(duration);
}
// Check type
if (typeof title !== 'string' || typeof duration !== 'number' || typeof quote !== 'string') {
console.error('Parameter type incorrect.');
return null;
}
return {
title,
duration,
quote,
methods: {
isLongerThan: (movie) => {
if (movie && typeof movie === 'object' && duration > movie.duration) { return true; }
return false;
},
logQuote: () => {
console.log('Quote:', quote);
}
}
};
}
// Task 2: Add movies
movies.push(createMovie('Star Wars', 121, 'If there\'s a bright center to the universe, you\'re on the planet that it\'s farthest from.'));
movies.push(createMovie('Pulp Fiction', 154, 'Do you know what they call a Quarter Pounder with Cheese in France?'));
movies.push(createMovie('Dirty Dancing', 100, 'Nobody puts Baby in a corner.'));
movies.push(createMovie('Forrest Gump', 142, 'Life is like a box of chocolates.'));
movies.push(createMovie('The Wizard of Oz', 101, 'Lions and tigers and bears, oh my!'));
movies.push(createMovie('Cabaret', 124, 'Life is a cabaret, old chum, so come to the cabaret.'));
// Task 3:
function getMovieByTitle(title) {
// Should maybe be async
if (title && typeof title === 'string') {
for (let movie of movies) {
if (movie.title.toLocaleLowerCase() === title.toLocaleLowerCase()) {
return movie;
}
}
}
return null;
}
function getAverageDuration() {
// Should maybe be async
let combinedDurations = 0;
for (let movie of movies) {
combinedDurations += movie.duration;
}
return (combinedDurations / movies.length);
}
//
// Display some results
var movie0 = document.getElementById('movie0');
var movie0Compared = document.getElementById('movie0-compared');
movie0.innerHTML = '<pre>' + JSON.stringify(movies[0], undefined, 2) + '</pre>';
movie0Compared.innerHTML = 'Is Movie 0 longer than Movie 1? <b>' + movies[0].methods.isLongerThan(movies[1]) + '</b>';
movies[0].methods.logQuote();
console.log('Pulp Fiction:', getMovieByTitle('pulp fiction').duration, 'min');
console.log('Average duration:', getAverageDuration().toFixed(0), 'min');
<h1>Results for: Movie0</h1>
<div id="movie0"></div>
<div id="movie0-compared"></div>
<br>
<br>
<br>
<br>
<br>

How to determine overlaps between selection ranges in a grid of items in Javascript

Imagine something like a grid of items.
The user can select multiple ranges of items in the grid. When there are multiple ranges (selections), I want to determine wether the last created range overlaps with an existing range.
The grid system can be compared with characters in a textarea, where it is possible to highlight multiple text ranges.
Every single range always exists of adjacent items, next to each
other, or the first item in the next row in the grid. (Also totally
similar to selecting text in a text document.)
When the user creates a range, it can overlap with an existing range, or even totally fit into an existing range.
A range is stored in memory as:
{
'rangeStartLineIndex': 2,
'rangeStartColIndex': 9,
'rangeEndLineIndex': 4,
'rangeEndColIndex': 7
}
Above range can be visualized as in the image. But note that the number of rows and columns of the grid is not constant.
The goal is to loop through the existing ranges, and look if the just created range overlaps with (or totally fits in) an existing range. If so, then take that existing range, and extend it so the created range merges with the one it overlaps with. So, it's kind of normalizing data.
Another example in code:
var ranges = []; // stores the range objects that are created earlier.
var createdRange = {...}; // range object just created.
for(var i = 0; i < ranges; i++) {
var overlap = doesThisOverlap(createdRange, ranges[i]);
if(overlap) {
// overlaps, which means we extend the existing range.
range[i].rangeStartLineIndex = Math.min(range[i].rangeStartLineIndex, createdRange.rangeStartLineIndex);
range[i].rangeStartColIndex = Math.min(range[i].rangeStartColIndex, createdRange.rangeStartColIndex);
range[i].rangeEndLineIndex = Math.max(range[i].rangeEndLineIndex, createdRange.rangeEndLineIndex);
range[i].rangeEndColIndex = Math.max(range[i].rangeEndColIndex, createdRange.rangeEndColIndex);
} else {
// means the new range does not extend an existing range, so add it.
ranges.push(createdRange);
}
}
function doesThisOverlap(rangeA, rangeB) {
// ???
}
When trying to implement the function doesThisOverlap, I end up with an excessive amount of if-blocks. I get confused, also because I've got the feeling there's an algorithm found for this.
What I also tried is adding up a range startpoint's line and col index to give it a 'score', (and do the same for its endpoint's line and column index).
Then compare that startpoint/endpoint scores between the ranges.
The problem is not really 2D, it becomes much easier if you represent the range as
{
rangeStart: 29,
rangeEnd:48
}
You can convert to this representation by calculating
lineIndex * COLUMN_NUMBER + columnIndex.
You should basically iterate all ranges and find min rangeStart and rangeEnd. Then you can convert the result to column/row using:
columnIndex = x % COLUMN_NUMBER;
lineIndex = parseInt(x / COLUMN_NUMBER).
One of the way to identify whether the createdRange overlaps with one of the ranges is to give each range start index and end index and then, check whether the indices of createdRange overlaps with indices of any other range or not.
First, Let us change the shape of the range object from better readability:
{
start: { row: 2, col: 9 },
end: { row: 4, col: 7 }
}
Mapping of this range object with the one you defined is simple:
rangeStartLineIndex => start.row
rangeStartColIndex => start.col
rangeEndLineIndex => end.row
rangeEndColIndex => end.col
With this out of the way, I would first point at one little mistake in the logic. In the for loop, you are checking if the createdRange overlaps with the current range or not. If not, you are adding that createdRange to the ranges array.
However, you only need to add the createdRange in the ranges IF none of the ranges overlap with createdRange
Thus, the correct for loop would look like:
var hasOverlap = false; // this will tell if any of the ranges overlap
for(var i = 0; i < ranges; i++) {
var overlap = doesThisOverlap(createdRange, ranges[i]);
if(overlap) {
// overlaps, which means we extend the existing range.
// some logic to update the overlapped ranges
hasOverlap = true; // a range has overlapped, set the flag to true
break;
}
}
// Did we see any overlap?
if(!hasOverlap) {
// no we did not, let us add this range to ranges array
// means the new range does not extend an existing range, so add it.
ranges.push(createdRange);
}
Alright, now let us see how to calculate the indices for the given range.
If we start assigning indices (starting from 0) from left to right in the grid,
simple math says that the index of the box in the row r and in the column c will be:
index = r * (COL + 1) + c [COL is the total number of columns in the grid]
Here are the helper functions which will help to calculate the indices given the range:
function getIndex(row, col, COL) {
return row * (COL + 1) + col;
}
function getIndices(range) {
var start = range.start;
var end = range.end;
var startIndex = getIndex(start.row, start.col, COLS);
var endIndex = getIndex(end.row, end.col, COLS);
return { start: startIndex, end: endIndex };
}
Note that getIndices takes a range and outputs an object with start and end indices. We can now calculate indices for createdRange and current range. And based on the indices, we would know whether the ranges overlap or not.
The problem now boils down to this:
We have a line AB, and given a new line PQ, find out whether the new line PQ overlaps AB or not. ( where A,B,P,Q are points on the number line, A < B and P < Q ).
Take pen and paper and draw a few lines. You will come to know that there only two condition when the lines will not overlap:
Either Q < A or B < P
Mapping this observations to our range object, we can say that:
P => createdRange.startIndex
Q => createdRange.endIndex
A => currentRange.startIndex
B => currentRange.endIndex
This is how it would look in the code:
var createdRangeIndices = getIndices(createdRange);
var hasOverlap = false;
for (var i = 0; i < ranges.length; i++) {
var currentRangeIndices = getIndices(ranges[i]);
var overlap = (createdRangeIndices.end < currentRangeIndices.start)
|| (currentRangeIndices.end < createdRangeIndices.start);
if (!overlap) {
// overlaps, which means we extend the existing range.
// some logic to update the overlapped ranges
hasOverlap = true;
break;
}
}
if (!hasOverlap) {
// means the new range does not extend an existing range, so add it.
ranges.push(createdRange);
}
Note that we got rid of the function doesThisOverlap. A simple flag would do.
All that remains now is the logic to update the range if there is an overlap.
A part of which you had already figured out in your question. We take the minimum of the starting index and maximum of the ending index. Here is the code for that:
for (var i = 0; i < ranges.length; i++) {
var currentRangeIndices = getIndices(ranges[i]);
var overlap = (createdRangeIndices.end < currentRangeIndices.start)
|| (currentRangeIndices.end < createdRangeIndices.start);
if (!overlap) {
// overlaps, which means we extend the existing range.
// some logic to update the overlapped ranges
var start, end;
if (currentRangeIndices.start < createdRangeIndices.start) {
start = ranges[i].start;
} else {
start = createdRange.start;
}
if (currentRangeIndices.end > createdRangeIndices.end) {
end = ranges[i].end;
} else {
end = createdRange.end;
}
ranges[i] = { start: start, end: end };
hasOverlap = true;
break;
}
}
And done!
Here is the complete code that combines all the bits and pieces together:
var ROWS = 7;
var COLS = 3;
function getIndex(row, col, COL) {
return row * (COL + 1) + col;
}
function getIndices(range) {
var start = range.start;
var end = range.end;
var startIndex = getIndex(start.row, start.col, COLS);
var endIndex = getIndex(end.row, end.col, COLS);
return { start: startIndex, end: endIndex };
}
function addRange(ranges, createdRange) {
var createdRangeIndices = getIndices(createdRange);
var hasOverlap = false;
for (var i = 0; i < ranges.length; i++) {
var currentRangeIndices = getIndices(ranges[i]);
var overlap =
createdRangeIndices.end < currentRangeIndices.start ||
currentRangeIndices.end < createdRangeIndices.start;
if (!overlap) {
var start, end;
if (currentRangeIndices.start < createdRangeIndices.start) {
start = ranges[i].start;
} else {
start = createdRange.start;
}
if (currentRangeIndices.end > createdRangeIndices.end) {
end = ranges[i].end;
} else {
end = createdRange.end;
}
ranges[i] = { start: start, end: end };
hasOverlap = true;
break;
}
}
if (!hasOverlap) {
// means the new range does not extend an existing range, so add it.
ranges.push(createdRange);
}
}
var ranges = []; // stores the range objects that are created earlier.
var rangesToAdd = [
{
start: { row: 2, col: 1 },
end: { row: 6, col: 0 }
},
{
start: { row: 6, col: 2 },
end: { row: 7, col: 2 }
},
{
start: { row: 3, col: 1 },
end: { row: 6, col: 1 }
},
{
start: { row: 6, col: 1 },
end: { row: 6, col: 2 }
}
];
rangesToAdd.forEach(aRange => addRange(ranges, aRange));
console.log(ranges);

HighChart: y axis categories are pushed off chart area, don't fit

I have this jsfiddle that will illustrate my problem: http://jsfiddle.net/qner9hwc/1/
Whenever you add a series that doesn't have the highest integer value, whether it would be at the bottom or in the middle somewhere, the top values get pushed off the chart area or the categories just don't fit inside the graph area at all.
The categories are being added dynamically and expect integer values. I have them being sorted in ascending order.
Try this use case:
Add a series "10", then "5" (you will see my problem here), then add "15" and it will correct itself
$('#addSeries').on('click', function () {
//first check if value is already a series
var seriesid = document.getElementById('txtValue').value;
getSeriesIDIndex(seriesid, function (idindex) {
//if it doesnt exist, add it to chart
if (idindex == -1) {
categories.push(parseInt(seriesid));
categories = categories.sort(sortNumber);
console.log(categories);
chart.yAxis[0].setCategories(categories, false);
getCategoryIndex(parseInt(seriesid), function (cindex) {
chart.addSeries({
name: seriesid,
data: [{
x: currentDate.getTime(),
y: cindex
}]
}, false);
for (i = 0; i < chart.series.length; i++) {
var cur_series = chart.series[i];
var cur_cindex;
getCategoryIndex(chart.series[i].name, function (cindex) {
cur_cindex = cindex;
});
for (var z = 0; z < cur_series.data.length; z++) {
if (cur_series.data[z].y >= cindex && cur_series.name !== seriesid) {
console.log('change!' + cur_series.data[z].y);
cur_series.data[z].y = cur_cindex;
}
}
}
console.log(chart.yAxis[0].categories);
chart.redraw();
});
}
});
});
This line is reponsible for your troubles:
cur_series.data[z].y = cur_cindex;
Proper code:
cur_series.data[z].update(cur_cindex, false);
You are updating only value, while you should be using point.update() for updating point. Demo: http://jsfiddle.net/qner9hwc/2/

Visualization of calendar events. Algorithm to layout events with maximum width

I need your help with an algorithm (it will be developed on client side with javascript, but doesn't really matter, I'm mostly interested in the algorithm itself) laying out calendar events so that each event box has maximum width. Please see the following picture:
Y axis is time. So if "Test event" starts at noon (for example) and nothing more intersects with it, it takes the whole 100% width. "Weekly review" intersects with "Tumbling YMCA" and "Anna/Amelia", but the latter two are not intersecting, so they all fill up 50%. Test3, Test4 and Test5 are all intersecting, so max width is 33.3% for each. But Test7 is 66% since Test3 is 33% fixed (see above) , so it takes all available space , which is 66%.
I need an algorithm how to lay this out.
Thanks in advance
Think of an unlimited grid with just a left edge.
Each event is one cell wide, and the height and vertical position is fixed based on starting and ending times.
Try to place each event in a column as far left as possible, without it intersecting any earlier event in that column.
Then, when each connected group of events is placed, their actual widths will be 1/n of the maximum number of columns used by the group.
You could also expand the events at the far left and right to use up any remaining space.
/// Pick the left and right positions of each event, such that there are no overlap.
/// Step 3 in the algorithm.
void LayoutEvents(IEnumerable<Event> events)
{
var columns = new List<List<Event>>();
DateTime? lastEventEnding = null;
foreach (var ev in events.OrderBy(ev => ev.Start).ThenBy(ev => ev.End))
{
if (ev.Start >= lastEventEnding)
{
PackEvents(columns);
columns.Clear();
lastEventEnding = null;
}
bool placed = false;
foreach (var col in columns)
{
if (!col.Last().CollidesWith(ev))
{
col.Add(ev);
placed = true;
break;
}
}
if (!placed)
{
columns.Add(new List<Event> { ev });
}
if (lastEventEnding == null || ev.End > lastEventEnding.Value)
{
lastEventEnding = ev.End;
}
}
if (columns.Count > 0)
{
PackEvents(columns);
}
}
/// Set the left and right positions for each event in the connected group.
/// Step 4 in the algorithm.
void PackEvents(List<List<Event>> columns)
{
float numColumns = columns.Count;
int iColumn = 0;
foreach (var col in columns)
{
foreach (var ev in col)
{
int colSpan = ExpandEvent(ev, iColumn, columns);
ev.Left = iColumn / numColumns;
ev.Right = (iColumn + colSpan) / numColumns;
}
iColumn++;
}
}
/// Checks how many columns the event can expand into, without colliding with
/// other events.
/// Step 5 in the algorithm.
int ExpandEvent(Event ev, int iColumn, List<List<Event>> columns)
{
int colSpan = 1;
foreach (var col in columns.Skip(iColumn + 1))
{
foreach (var ev1 in col)
{
if (ev1.CollidesWith(ev))
{
return colSpan;
}
}
colSpan++;
}
return colSpan;
}
Edit: Now sorts the events, instead of assuming they is sorted.
Edit2: Now expands the events to the right, if there are enough space.
The accepted answer describes an algorithm with 5 steps. The example implementation linked in the comments of the accepted answer implements only steps 1 to 4. Step 5 is about making sure the rightmost event uses all the space available. See event 7 in the image provided by the OP.
I expanded the given implementation by adding step 5 of the described algorithm:
$( document ).ready( function( ) {
var column_index = 0;
$( '#timesheet-events .daysheet-container' ).each( function() {
var block_width = $(this).width();
var columns = [];
var lastEventEnding = null;
// Create an array of all events
var events = $('.bubble_selector', this).map(function(index, o) {
o = $(o);
var top = o.offset().top;
return {
'obj': o,
'top': top,
'bottom': top + o.height()
};
}).get();
// Sort it by starting time, and then by ending time.
events = events.sort(function(e1,e2) {
if (e1.top < e2.top) return -1;
if (e1.top > e2.top) return 1;
if (e1.bottom < e2.bottom) return -1;
if (e1.bottom > e2.bottom) return 1;
return 0;
});
// Iterate over the sorted array
$(events).each(function(index, e) {
// Check if a new event group needs to be started
if (lastEventEnding !== null && e.top >= lastEventEnding) {
// The latest event is later than any of the event in the
// current group. There is no overlap. Output the current
// event group and start a new event group.
PackEvents( columns, block_width );
columns = []; // This starts new event group.
lastEventEnding = null;
}
// Try to place the event inside the existing columns
var placed = false;
for (var i = 0; i < columns.length; i++) {
var col = columns[ i ];
if (!collidesWith( col[col.length-1], e ) ) {
col.push(e);
placed = true;
break;
}
}
// It was not possible to place the event. Add a new column
// for the current event group.
if (!placed) {
columns.push([e]);
}
// Remember the latest event end time of the current group.
// This is later used to determine if a new groups starts.
if (lastEventEnding === null || e.bottom > lastEventEnding) {
lastEventEnding = e.bottom;
}
});
if (columns.length > 0) {
PackEvents( columns, block_width );
}
});
});
// Function does the layout for a group of events.
function PackEvents( columns, block_width )
{
var n = columns.length;
for (var i = 0; i < n; i++) {
var col = columns[ i ];
for (var j = 0; j < col.length; j++)
{
var bubble = col[j];
var colSpan = ExpandEvent(bubble, i, columns);
bubble.obj.css( 'left', (i / n)*100 + '%' );
bubble.obj.css( 'width', block_width * colSpan / n - 1 );
}
}
}
// Check if two events collide.
function collidesWith( a, b )
{
return a.bottom > b.top && a.top < b.bottom;
}
// Expand events at the far right to use up any remaining space.
// Checks how many columns the event can expand into, without
// colliding with other events. Step 5 in the algorithm.
function ExpandEvent(ev, iColumn, columns)
{
var colSpan = 1;
// To see the output without event expansion, uncomment
// the line below. Watch column 3 in the output.
//return colSpan;
for (var i = iColumn + 1; i < columns.length; i++)
{
var col = columns[i];
for (var j = 0; j < col.length; j++)
{
var ev1 = col[j];
if (collidesWith(ev, ev1))
{
return colSpan;
}
}
colSpan++;
}
return colSpan;
}
A working demo is available at http://jsbin.com/detefuveta/edit?html,js,output
See column 3 of the output for examples of expanding the rightmost events.
PS: This should really be a comment to the accepted answer. Unfortunately I don't have the privileges to comment.
Here's the same algorithm implemented for React using Typescript. You'll have to tweak it to fit your needs (of course), but it should prove useful for anyone working in React:
// Place concurrent meetings side-by-side (like GCal).
// #see {#link https://share.clickup.com/t/h/hpxh7u/WQO1OW4DQN0SIZD}
// #see {#link https://stackoverflow.com/a/11323909/10023158}
// #see {#link https://jsbin.com/detefuveta/edit}
// Check if two events collide (i.e. overlap).
function collides(a: Timeslot, b: Timeslot): boolean {
return a.to > b.from && a.from < b.to;
}
// Expands events at the far right to use up any remaining
// space. Returns the number of columns the event can
// expand into, without colliding with other events.
function expand(
e: Meeting,
colIdx: number,
cols: Meeting[][]
): number {
let colSpan = 1;
cols.slice(colIdx + 1).some((col) => {
if (col.some((evt) => collides(e.time, evt.time)))
return true;
colSpan += 1;
return false;
});
return colSpan;
}
// Each group contains columns of events that overlap.
const groups: Meeting[][][] = [];
// Each column contains events that do not overlap.
let columns: Meeting[][] = [];
let lastEventEnding: Date | undefined;
// Place each event into a column within an event group.
meetings
.filter((m) => m.time.from.getDay() === day)
.sort(({ time: e1 }, { time: e2 }) => {
if (e1.from < e2.from) return -1;
if (e1.from > e2.from) return 1;
if (e1.to < e2.to) return -1;
if (e1.to > e2.to) return 1;
return 0;
})
.forEach((e) => {
// Check if a new event group needs to be started.
if (
lastEventEnding &&
e.time.from >= lastEventEnding
) {
// The event is later than any of the events in the
// current group. There is no overlap. Output the
// current event group and start a new one.
groups.push(columns);
columns = [];
lastEventEnding = undefined;
}
// Try to place the event inside an existing column.
let placed = false;
columns.some((col) => {
if (!collides(col[col.length - 1].time, e.time)) {
col.push(e);
placed = true;
}
return placed;
});
// It was not possible to place the event (it overlaps
// with events in each existing column). Add a new column
// to the current event group with the event in it.
if (!placed) columns.push([e]);
// Remember the last event end time of the current group.
if (!lastEventEnding || e.time.to > lastEventEnding)
lastEventEnding = e.time.to;
});
groups.push(columns);
// Show current time indicator if today is current date.
const date = getDateWithDay(day, startingDate);
const today =
now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate();
const { y: top } = getPosition(now);
return (
<div
key={nanoid()}
className={styles.cell}
ref={cellRef}
>
{today && (
<div style={{ top }} className={styles.indicator}>
<div className={styles.dot} />
<div className={styles.line} />
</div>
)}
{groups.map((cols: Meeting[][]) =>
cols.map((col: Meeting[], colIdx) =>
col.map((e: Meeting) => (
<MeetingItem
now={now}
meeting={e}
viewing={viewing}
setViewing={setViewing}
editing={editing}
setEditing={setEditing}
setEditRndVisible={setEditRndVisible}
widthPercent={
expand(e, colIdx, cols) / cols.length
}
leftPercent={colIdx / cols.length}
key={e.id}
/>
))
)
)}
</div>
);
You can see the full source-code here. I'll admit that this is a highly opinionated implementation, but it would've helped me so I'll post it here to see if it helps anyone else!

Categories

Resources