Happy New Year: drawing clock in SQL
Some say SQL is not good at graphics.
Well, they have some point. Database engines lack scanner drivers, there is no easy way to do sepia, and magic wand, let's be honest, is just poor.
However, you can make some New Year paintings with SQL.
Let's make a New Year clock showing 12 o'clock in Oracle.
#1. Circle
First, we need a circle shape. In Photoshop, you could just select it from the toolbar, but in SQL, we need to use some math.
In math, a circle is defined by this formula: x2 + y2 = R2
.
x2 + y2
is, as we know, the square of a distance from the center of the coordinate grid, and R
is the radius. This means all points at distance
, which of course is a circle shape.R
from the center
SQL outputs data in tabular format. We will draw out art string by string, and for each line we need to know where to put the paint
(just some ASCII symbols). Since it's a circle, every line intersects it at most twice, and we shall put the symbols at the places where the circle and the line intersect. To know how many symbols are there from the beginning of the string, this formula is used:
ROUND(SQRT(1 - POWER((level - 21) / 20, 2)) * 20) AS angle
To make our circle more circle-shaped, we need to choose the characters that correspond to the angle of the circle line in each given place. These characters are the most similar to the lines: =/|\
Different characters correspond to different angles. To figure out the angle from the line number, we should use ACOS
:
ROUND(ACOS(angle / 20) / 3.1415926 * 4) * SIGN((line - 21) / 20) + 3 AS sign
This would split the circle into several sectors, and for each of these sectors a corresponding character will be chosen.
Finally, we need to avoid the gaps in the lines. To do this, we will not only fill the intersections, but all space from the previous (or next) intersection to the current. To calculated the width of the string on each line that will be filled with the characters, we will use LAG
and LEAD
, the analytic functions.
Now, let's put it all together:
WITH circle AS ( SELECT 1 AS layer, line, LPAD(RPAD(SUBSTR('=/|\=', sign, 1), width, SUBSTR('=/|\=', sign, 1)), 20 + width - angle, ' ') || RPAD(' ', (angle - width) * 2, ' ') || RPAD(RPAD(SUBSTR('=\|/=', sign, 1), width, SUBSTR('=\|/=', sign, 1)), 20 + width - angle, ' ') AS drawing FROM ( SELECT line, angle, ABS ( angle - CASE WHEN line < 21 THEN COALESCE(LAG(angle) OVER (ORDER BY line), 0) WHEN line > 21 THEN COALESCE(LEAD(angle) OVER (ORDER BY line), 0) ELSE angle END ) + 1 AS width, ROUND(ACOS(angle / 20) / 3.1415926 * 4) * SIGN((line - 21) / 20) + 3 AS sign FROM ( SELECT level AS line, ROUND(SQRT(1 - POWER((level - 21) / 20, 2)) * 20) AS angle FROM dual CONNECT BY level <= 41 ) q ) q ) SELECT * FROM circle
LAYER | LINE | DRAWING |
---|---|---|
1 | 1 | == |
1 | 2 | ============== |
1 | 3 | //// \\\\ |
1 | 4 | /// \\\ |
1 | 5 | // \\ |
1 | 6 | // \\ |
1 | 7 | // \\ |
1 | 8 | // \\ |
1 | 9 | // \\ |
1 | 10 | // \\ |
1 | 11 | / \ |
1 | 12 | // \\ |
1 | 13 | / \ |
1 | 14 | || || |
1 | 15 | | | |
1 | 16 | | | |
1 | 17 | || || |
1 | 18 | | | |
1 | 19 | | | |
1 | 20 | | | |
1 | 21 | | | |
1 | 22 | | | |
1 | 23 | | | |
1 | 24 | | | |
1 | 25 | || || |
1 | 26 | | | |
1 | 27 | | | |
1 | 28 | || || |
1 | 29 | \ / |
1 | 30 | \\ // |
1 | 31 | \ / |
1 | 32 | \\ // |
1 | 33 | \\ // |
1 | 34 | \\ // |
1 | 35 | \\ // |
1 | 36 | \\ // |
1 | 37 | \\ // |
1 | 38 | \\\ /// |
1 | 39 | \\\\ //// |
1 | 40 | ============== |
1 | 41 | == |
#2. Dial
Now, we shall draw the clock dial on a separate layer (yes, you can draw on layers in SQL).
We will use Roman numerals for the dial. Oracle has an internal function to format the numbers as Roman numerals, but SQL graphics is the first time I'm using this feature in practice.
The principle is the same: we should calculate the angle of each number, then the line and the column that intersect the dial at the given angle and put the number there.
We will only output the lines containing actual numbers. Each line will contain one or two numbers (1 and 6 go on their own lines, the others go in pairs). We can use MIN
and MAX
to distinguish them.
Here's what the lines of our dial will look like:
WITH dial AS ( SELECT 2, line, RPAD(' ', 20 - angle, ' ') || RPAD(rnf, angle * 2 - LENGTH(rns)) || RPAD(rns, 20 - angle + DECODE(rnf, NULL, 0, LENGTH(rns)) + 1) FROM ( SELECT line, angle, DECODE(MAX(h), MIN(h), NULL, TRIM(TO_CHAR(MAX(h), 'RN'))) AS rnf, TRIM(TO_CHAR(MIN(h), 'RN')) AS rns FROM ( SELECT level + 2 AS line, ROUND(SQRT(1 - POWER((level - 19) / 18, 2)) * 18) AS angle FROM dual CONNECT BY level <= 37 ) lines JOIN ( SELECT level AS h, ROUND(-COS(3.141592 * level / 6) * 18) + 21 AS hline FROM dual CONNECT BY level <= 12 ) hours ON hline = line GROUP BY line, angle ) q ) SELECT * FROM dial ORDER BY line
2 | LINE | RPAD('',20-ANGLE,'')||RPAD(RNF,ANGLE*2-LENGTH(RNS))||RPAD(RNS,20-ANGLE+DECODE(RNF,NULL,0,LENGTH(RNS))+1) |
---|---|---|
2 | 3 | XII |
2 | 5 | XI I |
2 | 12 | X II |
2 | 21 | IX III |
2 | 30 | VIII IV |
2 | 37 | VII V |
2 | 39 | VI |
Note that the shape is distorted: this is because of the gaps in the line numbers. It will be taken care of later.
#3. Hands and the center pin
Hands and the center pin are simple: we will use |||
for the hour hand, |
for the minute hand and O
for the pin. On a New Year clock the hands, fortunately, are vertical and directed in one way.
Here's the hour hand:
WITH hourhand AS ( SELECT 3 AS layer, level + 10 AS line, RPAD(' ', 20, ' ') || '|||' || RPAD(' ', 18, ' ') AS drawing FROM dual CONNECT BY level <= 10 ) SELECT * FROM hourhand ORDER BY line
LAYER | LINE | DRAWING |
---|---|---|
3 | 11 | ||| |
3 | 12 | ||| |
3 | 13 | ||| |
3 | 14 | ||| |
3 | 15 | ||| |
3 | 16 | ||| |
3 | 17 | ||| |
3 | 18 | ||| |
3 | 19 | ||| |
3 | 20 | ||| |
, the minute hand:
WITH minutehand AS ( SELECT 4 AS layer, level + 4 AS line, RPAD(' ', 21, ' ') || '|' || RPAD(' ', 19, ' ') AS drawing FROM dual CONNECT BY level <= 16 ) SELECT * FROM minutehand ORDER BY line
LAYER | LINE | DRAWING |
---|---|---|
4 | 5 | | |
4 | 6 | | |
4 | 7 | | |
4 | 8 | | |
4 | 9 | | |
4 | 10 | | |
4 | 11 | | |
4 | 12 | | |
4 | 13 | | |
4 | 14 | | |
4 | 15 | | |
4 | 16 | | |
4 | 17 | | |
4 | 18 | | |
4 | 19 | | |
4 | 20 | | |
and the pin:
WITH pin AS ( SELECT 5 AS layer, 21 AS line, RPAD(LPAD('O', 22, ' '), 41, ' ') AS drawing FROM dual ) SELECT * FROM pin ORDER BY line
LAYER | LINE | DRAWING |
---|---|---|
5 | 21 | O |
#4. Merging the layers.
Now we need to merge the layers.
The last layers should go on the foreground, the first ones on the background. Space symbol should be transparent
: if there is a character on the background layer, it should be visible through it.
To do it we we apply this trick:
- Output all rows from all layers in a single query, using
UNION ALL
- Split each row into the individual characters, one character per record
- Using
ROW_NUMBER()
, the analytical function, order the characters within each line and columns so that visible characters from higher layers go first, and spaces and characters from the lower layers go last:ROW_NUMBER() OVER (PARTITION BY line, col ORDER BY DECODE(cc, ' ', 1, 0), layer DESC) AS rn
- Filter the characters on
rn
so that only the topmost character from the each line and column is returned - Concatenate all characters line-wise, using a recursive query and
SYS_CONNECT_BY_PATH
. This function requires a separator, and we will use a question mark,?
, for this purpose, since it's not used in our art. - Remove the separator from the concatenated strings, using
REPLACE
- Output the result
#5. Result
Here's our query and drawing:
WITH circle AS ( SELECT 1 AS layer, line, LPAD(RPAD(SUBSTR('=/|\=', sign, 1), width, SUBSTR('=/|\=', sign, 1)), 20 + width - angle, ' ') || RPAD(' ', (angle - width) * 2, ' ') || RPAD(RPAD(SUBSTR('=\|/=', sign, 1), width, SUBSTR('=\|/=', sign, 1)), 20 + width - angle, ' ') AS drawing FROM ( SELECT line, angle, ABS ( angle - CASE WHEN line < 21 THEN COALESCE(LAG(angle) OVER (ORDER BY line), 0) WHEN line > 21 THEN COALESCE(LEAD(angle) OVER (ORDER BY line), 0) ELSE angle END ) + 1 AS width, ROUND(ACOS(angle / 20) / 3.1415926 * 4) * SIGN((line - 21) / 20) + 3 AS sign FROM ( SELECT level AS line, ROUND(SQRT(1 - POWER((level - 21) / 20, 2)) * 20) AS angle FROM dual CONNECT BY level <= 41 ) q ) q ), dial AS ( SELECT 2, line, RPAD(' ', 20 - angle, ' ') || RPAD(rnf, angle * 2 - LENGTH(rns)) || RPAD(rns, 20 - angle + DECODE(rnf, NULL, 0, LENGTH(rns)) + 1) FROM ( SELECT line, angle, DECODE(MAX(h), MIN(h), NULL, TRIM(TO_CHAR(MAX(h), 'RN'))) AS rnf, TRIM(TO_CHAR(MIN(h), 'RN')) AS rns FROM ( SELECT level + 2 AS line, ROUND(SQRT(1 - POWER((level - 19) / 18, 2)) * 18) AS angle FROM dual CONNECT BY level <= 37 ) lines JOIN ( SELECT level AS h, ROUND(-COS(3.141592 * level / 6) * 18) + 21 AS hline FROM dual CONNECT BY level <= 12 ) hours ON hline = line GROUP BY line, angle ) q ), hourhand AS ( SELECT 3 AS layer, level + 10 AS line, RPAD(' ', 20, ' ') || '|||' || RPAD(' ', 18, ' ') AS drawing FROM dual CONNECT BY level <= 10 ), minutehand AS ( SELECT 4 AS layer, level + 4 AS line, RPAD(' ', 21, ' ') || '|' || RPAD(' ', 19, ' ') AS drawing FROM dual CONNECT BY level <= 16 ), pin AS ( SELECT 5 AS layer, 21 AS line, RPAD(LPAD('O', 22, ' '), 41, ' ') AS drawing FROM dual ), m AS ( SELECT line, col, cc FROM ( SELECT line, col, cc, ROW_NUMBER() OVER (PARTITION BY line, col ORDER BY DECODE(cc, ' ', 1, 0), layer DESC) AS rn FROM ( SELECT cols.*, layers.*, SUBSTR(drawing, col, 1) AS cc FROM ( SELECT level AS col FROM dual CONNECT BY level <= 50 ) cols CROSS JOIN ( SELECT * FROM circle UNION ALL SELECT * FROM dial UNION ALL SELECT * FROM hourhand UNION ALL SELECT * FROM minutehand UNION ALL SELECT * FROM pin ) layers ) ) WHERE rn = 1 ) SELECT REPLACE(drawing, '?') AS drawing FROM ( SELECT SYS_CONNECT_BY_PATH(cc, '?') AS drawing, line, CONNECT_BY_ISLEAF AS leaf FROM m mi START WITH mi.col = 1 CONNECT BY mi.line = PRIOR mi.line AND mi.col = PRIOR mi.col + 1 ) WHERE leaf = 1 ORDER BY line
DRAWING |
---|
== |
============== |
//// XII \\\\ |
/// \\\ |
// XI | I \\ |
// | \\ |
// | \\ |
// | \\ |
// | \\ |
// | \\ |
/ ||| \ |
//X ||| II\\ |
/ ||| \ |
|| ||| || |
| ||| | |
| ||| | |
|| ||| || |
| ||| | |
| ||| | |
| ||| | |
| IX O III | |
| | |
| | |
| | |
|| || |
| | |
| | |
|| || |
\ / |
\\VIII IV// |
\ / |
\\ // |
\\ // |
\\ // |
\\ // |
\\ // |
\\ VII V // |
\\\ /// |
\\\\ VI //// |
============== |
== |
You are definitely insane ;)
Happy New Year (if is not 14 for you ;)
ts
31 Dec 10 at 23:35
:)
This is awesome. You are a MySQL rockstar!
Andy
1 Jan 11 at 01:49
WOW! You are the SQL god!
:-O
1 Jan 11 at 05:23
You are a mysql wizard.
Mo
1 Jan 11 at 19:14
Thank you all guys and happy New Year!
@ts: no, it’s 1st, like all over the world :) It’s Christmas, a religious holiday, that is on Jan 7th (Dec 24th Julian style).
@Andy and @Mo: it’s actually Oracle. It’s a little bit harder on MySQL (though of course still possible!)
Quassnoi
2 Jan 11 at 01:44
LOL Alex! You definitely know how to impress. I’m not sure if this wins you SQL innovation award of the year, or nerd of the year! Happy Holidays mate.
Ian
2 Jan 11 at 10:46
@Ian and all those interested: the previous New Year article had 4 similar paintings: https://explainextended.com/2009/12/31/happy-new-year/
Quassnoi
2 Jan 11 at 12:45
Love the fireworks! Can’t say I would have ever thought, “hey, let’s use SQL to draw firework glyphs!” :)
Perhaps you could title yourself “SQL Artist Extraordinaire”.
Ian
2 Jan 11 at 14:19
very very nice. you are always rock.
Halim
4 Jan 11 at 14:17