root = {
let population = await d3.csv("https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2023/2023-08-22/population.csv");
let populationedFilter = population.filter(pop => pop.coa_name == "France" | pop.coa_name == "Canada" | pop.coa_name == "Germany" | pop.coa == "USA" | pop.coa == "TUR" | pop.coa == "GBR" | pop.coa == "ITA" | pop.coa == "RUS");
let populationedSimplified = d3.rollups(populationedFilter.filter(pop => pop.year == 2022), v => d3.sum(v, d => d.refugees), d => d.coa_name, d => d.coo_name);
let allCountries = populationedFilter.map(d => d.coo_name);
let groupAggregator = function(subset) {
let tempArray = [];
for (let i = 0; i < subset.length; i++) {
tempArray.push(subset[i][1]);
}
let sum = 0;
tempArray.forEach(a => {sum += a;});
return ["Other", sum];
}
let aggregatedArray = [];
for (let i = 0; i < populationedSimplified.length; i++) {
let top10 = [populationedSimplified[i][0], populationedSimplified[i][1].sort((a, b) => b[1] - a[1]).slice(0,10)];
let toBeGrouped = populationedSimplified[i][1].sort((a, b) => b[1] - a[1]).slice(10,);
aggregatedArray.push(top10);
aggregatedArray[i][1].push(groupAggregator(toBeGrouped));
}
aggregatedArray.sort((a, b) => (a[0] > b[0]) ? 1 : ((b[0] > a[0]) ? -1 : 0));
let objectTimeSeriesConverter = function(arrival, origin) {
let tempArray = [];
let year = [2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022];
if (origin == "Other") {
let top10Countries = populationedSimplified
.filter(d => d[0] == arrival)[0][1]
.sort((a, b) => b[1] - a[1])
.slice(0,10)
.map(d => d[0]);
let otherCountryRollup = d3.rollups(populationedFilter.filter(pop => pop.coa_name == arrival)
.filter(pop => allCountries.includes(pop.coo_name))
.filter(pop => !top10Countries.includes(pop.coo_name)), v => d3.sum(v, d => d.refugees), d => d.year);
for (let y = 0; y < year.length; y++) {
if (otherCountryRollup.filter(d => d[0] == year[y])[0] !== undefined) {
let tempObject = {name: false,
value: false};
tempObject.name = String(year[y]);
tempObject.value = otherCountryRollup.filter(d => d[0] == year[y])[0][1];
tempArray.push(tempObject);
}
} ;
} else {
for (let y = 0; y < year.length; y++) {
let popSelect = populationedFilter.filter(pop => pop.coa_name == arrival & pop.coo_name == origin & pop.year == year[y])
if (popSelect[0] !== undefined) {
let tempObject = {name: false,
value: false};
tempObject.name = String(year[y]);
tempObject.value = Number(popSelect[0]["refugees"]);
tempArray.push(tempObject);
} else {
let tempObject = {name: false,
value: false};
tempObject.name = String(year[y]);
tempObject.value = 0;
tempArray.push(tempObject);
};
};
};
return tempArray;
};
let objectChildConverter = function(child, k, parent) {
let tempObject = {name: false,
value: false,
children: false};
tempObject.name = child[k][0];
tempObject.value = child[k][1];
tempObject.children = objectTimeSeriesConverter(parent, child[k][0]);
return tempObject;
}
let objectParentConverter = function(data, i) {
let tempObject = {name: false,
children: false};
tempObject.name = data[i][0];
let tempChildren = []
for (let k = 0; k < data[i][1].length; k++) {
tempChildren.push(objectChildConverter(data[i][1], k, data[i][0]))
}
tempObject.children = tempChildren;
return tempObject;
};
let data = [];
for (let i = 0; i < aggregatedArray.length; i++) {
data.push(objectParentConverter(aggregatedArray,i));
}
data[6].name = "United Kingdom"
data[7].name = "United States"
return d3.hierarchy({
name: "refugee_data",
children: data})
.sum(d => d.value)
.sort((a, b) => b.name - a.name)
.eachAfter(d => d.index = d.parent ? d.parent.index = d.parent.index + 1 || 0 : 0)
}
// Creates a set of bars for the given data node, at the specified index.
function bar(svg, down, d, selector) {
const g = svg.insert("g", selector)
.attr("class", "enter")
.attr("transform", `translate(0,${marginTop + barStep * barPadding})`)
.attr("text-anchor", "end")
.style("font-weight", "300")
.style("font-size", "11px");
// .style("font-", "11px Lato Light");
const bar = g.selectAll("g")
.data(d.children)
.join("g")
.attr("cursor", d => !d.children ? null : "pointer")
.on("click", (event, d) => down(svg, d));
bar.append("text")
.attr("x", marginLeft - 6)
.attr("y", barStep * (1 - barPadding) / 2)
.attr("dy", ".35em")
.text(function(d) {
if (d.data.name.length > 18) {
return d.data.name.substring(0,18)+'...';
} else {
return d.data.name
}
});
bar.append("rect")
.attr("x", x(0))
.attr("width", d => x(d.value) - x(0))
.attr("height", barStep * (1 - barPadding));
return g;
}
async function down(svg, d) {
if (!d.children || d3.active(svg.node())) return;
if (d3.active(svg.node())) return;
const bottomLevelIndicator = ["2010", "2011", "2012", "2013", "2014", "2015", "2016", "2017", "2018", "2019", "2020", "2021", "2022"].includes(d.data.children[0].name)
// Rebind the current node to the background.
svg.select(".background").datum(d);
// Define two sequenced transitions.
const transition1 = svg.transition().duration(duration);
const transition2 = transition1.transition();
// Mark any currently-displayed bars as exiting.
const exit = svg.selectAll(".enter")
.attr("class", "exit");
// Update the x-axis.
if (bottomLevelIndicator) {
// Entering nodes immediately obscure the clicked-on bar, so hide it.
exit.selectAll("rect")
.transition()
.attr("fill-opacity", p => p !== d ? 0 : null)
.attr("height", 2.3)
.transition()
.duration(300)
.attr("width",0);
exit.transition()
.delay(300)
.duration(duration)
.attr("fill-opacity", 0)
.remove();
svg.selectAll(".above")
.transition()
.duration(300)
.attr("fill-opacity", 0)
.attr("opacity",0);
xBottom.domain(d3.extent(d.data.children, d => new Date(d.name)));
yBottom.domain(d3.extent(d.data.children, d => d.value));
svg.append("g")
.call(xAxisBottom)
.transition()
.delay(500)
.duration(300)
.attr("fill-opacity", 1)
.attr("opacity", 1);
svg.append("g")
.call(yAxisBottom)
.transition()
.delay(500)
.duration(300)
.attr("fill-opacity", 1)
.attr("opacity", 1);
const line = d3.line()
.x(d => xBottom(new Date(d.name)))
.y(d => yBottom(d.value));
const l = length(line(d.data.children));
svg.append("path")
.datum(d.data.children)
.attr("fill", "none")
.attr("stroke", color(true))
.attr("stroke-width", 2.5)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-dasharray", `0,${l}`)
.attr("d", line)
.attr("class", "bottomline")
.transition()
.delay(500)
.duration(600)
.ease(d3.easeLinear)
.attr("stroke-dasharray", `${l},0`);
} else {
// Entering nodes immediately obscure the clicked-on bar, so hide it.
exit.selectAll("rect")
.attr("fill-opacity", p => p === d ? 0 : null);
// Transition exiting bars to fade out.
exit.transition(transition1)
.attr("fill-opacity", 0)
.remove();
// Enter the new bars for the clicked-on data.
// Per above, entering bars are immediately visible.
const enter = bar(svg, down, d, ".y-axis")
.attr("fill-opacity", 0);
// Have the text fade-in, even though the bars are visible.
enter.transition(transition1)
.attr("fill-opacity", 1);
// Transition entering bars to their new y-position.
enter.selectAll("g")
.attr("transform", stack(d.index))
.transition(transition1)
.attr("transform", stagger());
// Update the x-scale domain.
x.domain([0, d3.max(d.children, d => d.value)]);
svg.selectAll(".x-axis").transition(transition2)
.call(xAxis);
// Transition entering bars to the new x-scale.
enter.selectAll("g").transition(transition2)
.attr("transform", (d, i) => `translate(0,${barStep * i})`);
// Color the bars as parents; they will fade to children if appropriate.
enter.selectAll("rect")
.attr("fill", color(true))
.attr("fill-opacity", 1)
.transition(transition2)
.attr("fill", d => color(!!d.children))
.attr("width", d => x(d.value) - x(0));
}
const transition4 = svg.transition().duration(400)
svg.selectAll(".enter_header")
.attr("class", "lead exit_header")
.transition(transition4)
.attr("x",-800)
.remove();
svg.selectAll(".enter_primary_header")
.attr("class", "h3 exit_primary_header")
.transition(transition4)
.attr("x",-800)
.remove();
if (d.data.name == "refugee_data") {
primaryheader(svg, "starter");
subheader(svg, "starter");
svg.selectAll("#background_image")
.transition()
.duration(400)
.attr("opacity", "0")
.remove();
} else if (bottomLevelIndicator) {
primaryheader(svg, "bottom", d.data.name, d.parent.data.name);
subheader(svg, "bottom");
svg.selectAll("#background_image")
.transition()
.duration(400)
.attr("opacity", "0")
.remove();
} else {
primaryheader(svg, "middle", d.data.name, undefined, "right");
subheader(svg, "middle", "right");
backgroundImage(svg, d.data.name);
svg.selectAll("#background_image")
.transition()
// .delay(duration*1.9)
.duration(1500)
.attr("opacity", ".55");
};
}
function up(svg, d) {
if (!d.parent || !svg.selectAll(".exit").empty()) return;
// Rebind the current node to the background.
svg.select(".background").datum(d.parent);
const bottomLevelIndicator = ["2010", "2011", "2012", "2013", "2014", "2015", "2016", "2017", "2018", "2019", "2020", "2021", "2022"].includes(d.data.children[0].name)
// Define two sequenced transitions.
const transition1 = svg.transition().duration(duration);
const transition2 = transition1.transition();
if (bottomLevelIndicator) {
svg.selectAll(".bottom")
.transition()
.duration(1500)
.attr("fill-opacity", 0)
.attr("opacity",0)
.remove();
svg.selectAll(".bottom")
.transition()
.duration(1500)
.attr("fill-opacity", 0)
.attr("opacity",0)
.remove();
svg.selectAll(".above")
.transition()
.duration(1500)
.attr("fill-opacity", 1)
.attr("opacity",1);
svg.selectAll(".bottomline")
.transition()
.duration(400)
.attr("stroke-dasharray", `0,1500`)
.remove();
}
// Mark any currently-displayed bars as exiting.
const exit = svg.selectAll(".enter")
.attr("class", "exit");
// Update the x-scale domain.
x.domain([0, d3.max(d.parent.children, d => d.value)]);
// Update the x-axis.
svg.selectAll(".x-axis").transition(transition1)
.call(xAxis);
//svg.selectAll("rect")
// .attr("cursor", "alias");
// .attr("class", "background")
// .attr("fill", "none");
// Transition exiting bars to the new x-scale.
exit.selectAll("g").transition(transition1)
.attr("transform", stagger());
// Transition exiting bars to the parent’s position.
exit.selectAll("g").transition(transition2)
.attr("transform", stack(d.index));
// Transition exiting rects to the new scale and fade to parent color.
exit.selectAll("rect").transition(transition1)
.attr("width", d => x(d.value) - x(0))
.attr("fill", color(true));
// Transition exiting text to fade out.
// Remove exiting nodes.
exit.transition(transition2)
.attr("fill-opacity", 0)
.remove();
// Enter the new bars for the clicked-on data's parent.
const enter = bar(svg, down, d.parent, ".exit")
.attr("fill-opacity", 0);
enter.selectAll("g")
.attr("transform", (d, i) => `translate(0,${barStep * i})`);
// Transition entering bars to fade in over the full duration.
enter.transition(transition2)
.attr("fill-opacity", 1);
// Color the bars as appropriate.
// Exiting nodes will obscure the parent bar, so hide it.
// Transition entering rects to the new x-scale.
// When the entering parent rect is done, make it visible!
<!-- enter.selectAll("rect") -->
<!-- .attr("fill", d => color(!!d.children)) -->
<!-- .attr("fill-opacity", p => p === d ? 0 : null) -->
<!-- .transition(transition2) -->
<!-- .attr("width", d => x(d.value) - x(0)) -->
<!-- .on("end", function(p) { d3.select(this).attr("fill-opacity", 1); }); -->
enter.selectAll("rect")
.attr("fill", d => color(!!d.children));
const transition4 = svg.transition().duration(400)
svg.selectAll(".enter_header")
.attr("class", "lead exit_header")
.transition(transition4)
.attr("x",800)
.remove();
svg.selectAll(".enter_primary_header")
.attr("class", "h3 exit_primary_header")
.transition(transition4)
.attr("x",800)
.remove();
if (d.parent.data.name == "refugee_data") {
primaryheader(svg, "starter");
subheader(svg, "starter");
svg.selectAll("#background_image")
.transition()
.duration(400)
.attr("opacity", "0")
.remove();
} else {
primaryheader(svg, "middle", d.parent.data.name, undefined, "left");
subheader(svg, "middle", "left");
backgroundImage(svg, d.parent.data.name);
svg.selectAll("#background_image")
.transition()
// .delay(duration*1.9)
.duration(1500)
.attr("opacity", ".55");
};
}
function stack(i) {
let value = 0;
return d => {
const t = `translate(${x(value) - x(0)},${barStep * i})`;
value += d.value;
return t;
};
}
function stagger() {
let value = 0;
return (d, i) => {
const t = `translate(${x(value) - x(0)},${barStep * i})`;
value += d.value;
return t;
};
}
x = d3.scaleLinear().range([marginLeft, width - marginRight]);
xAxis = g => g
.attr("class", "x-axis above")
.attr("transform", `translate(0,${marginTop})`)
//.style("font", "11px Lato Light")
.style("font-weight", "300")
.style("font-size", "11px")
.call(d3.axisTop(x).ticks(width / 80, "s"))
.call(g => (g.selection ? g.selection() : g).select(".domain").remove());
xBottom = d3.scaleTime()
.range([marginLeft, width - marginRight]);
xAxisBottom = g => g
.attr("class", "x-axis bottom")
.attr("transform", `translate(0, ${height - marginTop})`)
//.style("font", "11px Lato Light")
.style("font-weight", "300")
.style("font-size", "11px")
.call(d3.axisBottom(xBottom).ticks(width / 80).tickFormat(d3.timeFormat('%Y')))
.attr("opacity", 0)
.call(g => (g.selection ? g.selection() : g).select(".domain").remove());
yBottom = d3.scaleLinear()
.domain([0, 100])
.range([height - marginBottom, marginTop]);
yAxis = g => g
.attr("class", "y-axis above")
.attr("class", "above")
.attr("transform", `translate(${marginLeft + 0.5},0)`)
.call(g => g.append("line")
.attr("stroke", "currentColor")
.attr("y1", marginTop)
.attr("y2", height - marginBottom));
yAxisBottom = g => g
.attr("class", "y-axis bottom")
.attr("transform", `translate(${marginLeft + 0.5},0)`)
.attr("opacity", 0)
//.style("font", "11px Lato Light")
.style("font-weight", "300")
.style("font-size", "11px")
.call(d3.axisLeft(yBottom).ticks(height / 40, "s"))
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1));
length = (path) => d3.create("svg:path").attr("d", path).node().getTotalLength();
height = {
<!-- let max = 1; -->
<!-- root.each(d => d.children && (max = Math.max(max, d.children.length))); -->
return 11 * barStep + marginTop + marginBottom;
};
marginBottom = 100;
marginLeft = 100;
marginTop = 100;
marginRight = 30;
barStep = 27;
barPadding = 3 / barStep;
color = d3.scaleOrdinal([true, false], ["#738784ff","#54534380"]);
duration = 750;
function backgroundImage(svg, name) {
let imageLink = countryImageMap.filter(a => a.nameToMap == name)[0]["link"];
svg.append("defs")
.append("linearGradient")
.attr("id", "Gradient1")
.append("stop").attr("offset", "0").attr("stop-color", "black");
svg.selectAll("#Gradient1")
.append("stop")
.attr("offset", ".5")
.attr("stop-color", "white");
svg.selectAll("#Gradient1")
.append("stop")
.attr("offset", ".87")
.attr("stop-color", "black");
svg.selectAll("defs")
.append("mask")
.attr("id", "Mask1")
.append("rect")
.attr("x",marginTop*1.1)
.attr("y1", marginTop*1.1)
.attr("y2",height - marginBottom)
.attr('width', width)
.attr('height', height - marginBottom)
.attr("fill","url(#Gradient1)");
svg.selectAll("defs")
.append("linearGradient")
.attr("id", "Gradient2")
.attr("x1", "0")
.attr("x2", "0")
.attr("y1", "0")
.attr("y2", "1")
.append("stop")
.attr("offset", ".12")
.attr("stop-color", "black")
svg.selectAll("#Gradient2")
.append("stop")
.attr("offset", "1")
.attr("stop-color", "white");
svg.selectAll("defs")
.append("mask")
.attr("id", "Mask2")
.append("rect")
.attr("mask","url(#Mask1)")
.attr("x",marginTop*1.1)
.attr("y1", marginTop*1.1)
.attr("y2",height - marginBottom)
.attr('width', width)
.attr('height', height - marginBottom)
.attr("fill","url(#Gradient2)");
svg.insert("svg", ":first-child")
.append("image")
.attr("id","background_image")
.attr('xlink:href', imageLink)
.attr("width", width-marginLeft)
.attr("height", height - marginBottom)
.attr("y", marginTop)
.attr("x", marginLeft)
.attr("preserveAspectRatio", "xMidYMid slice")
.attr("opacity", "0")
.attr("mask", "url(#Mask2)");
};
countryImageMap = [
{nameToMap: "Canada",
link: "./data_stories/20230922_images/data_story_canada.jpg?fit=crop&w=200&h=200"},
{nameToMap: "France",
link: "./data_stories/20230922_images//data_story_paris_skyline.jpg?fit=crop&w=200&h=200"}, // https://www.pexels.com/@alecdoua/
{nameToMap: "Germany",
link: "./data_stories/20230922_images/data_story_germany.jpg?fit=crop&w=200&h=200"}, //https://www.pexels.com/@lander-lai-64457665/
{nameToMap: "Italy",
link: "./data_stories/20230922_images/data_story_italy.jpg?fit=crop&w=200&h=200"}, // https://www.pexels.com/@yankrukov/f
{nameToMap: "Russian Federation",
link: "./data_stories/20230922_images/data_story_kremlin.jpg?fit=crop&w=200&h=200"}, // https://www.pexels.com/@67117688/
{nameToMap: "Türkiye",
link: "./data_stories/20230922_images/turkey_photo2.jpg?fit=crop&w=200&h=200"},
{nameToMap: "United Kingdom",
link: "./data_stories/20230922_images/data_story_london_skyline.jpg?fit=crop&w=200&h=200"}, // https://www.pexels.com/@skitterphoto/
{nameToMap: "United States",
link: "./data_stories/20230922_images/data_story_statue_of_liberty.jpg?fit=crop&w=200&h=200"} // https://www.pexels.com/@domenico-solimeno-6463499/
];
screenWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
async function subheader(svg, starter, from) {
let marginMultiple;
if (screenWidth <= 768) { // You can adjust this threshold as needed
marginMultiple = .15; // Set a smaller font size for mobile screens
} else {
marginMultiple = .85;
}
if (starter == "starter") {
const enterHeader = svg.append("text")
.attr("id","subheader_top")
//.attr("class", "enter_header")
.attr("x", -800)
.attr("y", (marginTop / 1.6))
.attr("class", "lead enter_header")
.text("Click on a country below to see the home countries")
.style("font-size", "16px");
enterHeader.transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
} else if (starter == "middle" && from == "right") {
const enterHeader = svg.append("text")
.attr("id","subheader_top")
//.attr("class", "enter_header")
.attr("x", 800)
.attr("y", (marginTop / 1.6))
.attr("class", "lead enter_header")
.text("Click on a country below to see historical trends")
.style("font-size", "16px");
enterHeader.transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
} else if (starter == "middle" && from == "left") {
const enterHeader = svg.append("text")
.attr("id","subheader_top")
//.attr("class", "enter_header")
.attr("x", -800)
.attr("y", (marginTop / 1.6))
.attr("class", "lead enter_header")
.text("Click on a country below to see historical trends")
.style("font-size", "16px");
enterHeader.transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
} else {
const enterHeader = svg.append("text")
.attr("id","subheader_top")
//.attr("class", "enter_header")
.attr("x", 800)
.attr("y", (marginTop / 1.6))
.attr("class", "lead enter_header")
.text("Click anywhere to return to the previous view")
.style("font-size", "16px");
enterHeader.transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
}
}
async function primaryheader(svg, starter, destination, origin, from) {
let marginMultiple;
let fontSize;
if (screenWidth <= 768) {
marginMultiple = .15;
fontSize = "1.20rem";
} else {
marginMultiple = .85;
fontSize = "1.25rem";
};
if (starter == "starter") {
const enterHeader = svg.append("text")
.attr("id","header_top")
.attr("class", "h3 enter_primary_header")
.attr("x", -800)
.attr("y", (marginTop / 2.8))
.attr("opacity", 0)
.style("font-size", fontSize)
.text("Current Refugees by Destination").transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
} else if (starter == "middle" && from == "left") {
const enterHeader = svg.append("text")
.attr("id","header_top")
.attr("class", "h3 enter_primary_header")
.attr("x", -800)
.attr("y", (marginTop / 2.8))
.style("font-size", fontSize)
.text("Refugees in" + " " + destination + " by Home Country").transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
} else if (starter == "middle" && from == "right") {
const enterHeader = svg.append("text")
.attr("id","header_top")
.attr("class", "h3 enter_primary_header")
.attr("x", 800)
.attr("y", (marginTop / 2.8))
.style("font-size", fontSize)
.text("Refugees in" + " " + destination + " by Origin").transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
} else if (starter == "bottom") {
const enterHeader = svg.append("text")
.attr("id","header_top")
.attr("class", "h3 enter_primary_header")
.attr("x", 800)
.attr("y", (marginTop / 2.8))
.style("font-size", fontSize)
.text(destination + " Refugees in " + origin).transition()
.duration(400)
.attr("x", (marginLeft*marginMultiple));
}
}
chart = {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", width)
.attr("height", height)
.attr("style", "max-width: 100%; height: auto;");
//x.domain([0, d3.max(population_for_viz, d => d.refugees)])
x.domain([0, root.value])
svg.append("rect")
.attr("pointer-events", "all")
.attr("width", width)
.attr("height", height)
.attr("class", "background")
.attr("fill","none")
.on("click", (event, d) => up(svg, d));
svg.append("g")
.call(xAxis);
svg.append("g")
.call(yAxis);
down(svg, root);
return svg.node();
}
What We Do Best
Free State Analytics provides a wide range of data science services, but we are best known for our UI development and data visualizations skills.
We help clients discover new insights and make better predictions.
Data Visualizations, Infographics & Reporting
Despite the increased emphasis on AI and statistical learning, the most useful and practical form of analysis remains data visualizations.
Free State Analytics is known for our ability to convey insights in a clean and accessible way to stakeholders, using:
- data visualizations
- static reports
- in-person presentations
- automated dashboards
Our data visualizations can be highly interactive for niche use cases, illustrate key findings in academic writing, or reveal trends and insights within business intelligence reports.
Check out our interactive data viz below.
See more examples here section.
R Shiny Development
Are you disappointed with your Shiny applications?
Our Shiny apps look different.
In fact, they look more like websites.
Clients find our applications both easy to read and navigate.
These clean designs are paired with the advanced use cases people expect from Shiny.
Are you disappointed with your Shiny applications?
Our Shiny apps look different.
In fact, they look more like websites.
Clients find our applications both easy to read and navigate.
These clean designs are paired with the advanced use cases people expect from Shiny.
UI / UX Prototype Development
Prove a concept—today!
Many of our clients are product developers, seeking a prototype to prove a concept to investors or the marketplace.
Some are in the early stages of funding or recent recipients of small business innovation research grants.
Our prototypes typically involve highly specialized use cases and use common UI frameworks, including:
- Bootstrap 5
- Fomantic-UI
- Microsoft’s Fluent
Check out how our prototype helped diagnose autism spectrum disorder in children.
Read the full story here.
Want to Learn More?
Contact us for a zoom call.