diff --git a/config.yaml b/config.yaml index 63c5a6b..3f92763 100644 --- a/config.yaml +++ b/config.yaml @@ -34,4 +34,6 @@ site: number: 10 archive: title: Archive + number: 10 + tag: number: 10 \ No newline at end of file diff --git a/config/mPost.go b/config/mPost.go index b2e922f..12cd2a2 100644 --- a/config/mPost.go +++ b/config/mPost.go @@ -4,6 +4,7 @@ type mPost struct { TocTitle string `yaml:"toc_title"` RecentPost mRecentPost `yaml:"recent_post"` Archive mArchive `yaml:"archive"` + Tag mTag `yaml:"tag"` } type mRecentPost struct { @@ -15,3 +16,7 @@ type mArchive struct { Title string `yaml:"title"` Number int `yaml:"number"` } + +type mTag struct { + Number int `yaml:"number"` +} diff --git a/internal/mApp/mApp.go b/internal/mApp/mApp.go index 90432cf..887fe03 100644 --- a/internal/mApp/mApp.go +++ b/internal/mApp/mApp.go @@ -20,9 +20,14 @@ type MApp struct { lute *lute.Lute engine *gin.Engine - Posts []*model.MPost - TaggedPosts []*model.MPost - CategorizedPosts []*model.MPost + Posts []*model.MPost + + Tags map[string]string + TagsCount map[string]int + Categories map[string]string + CategoriesCount map[string]int + TaggedPosts map[string][]*model.MPost + CategorizedPosts map[string][]*model.MPost SrcFiles []model.MFileInfo } @@ -56,7 +61,34 @@ func NewMApp(cfg *config.MConfig) *MApp { Port: cfg.Port, Config: cfg, + Tags: make(map[string]string), + TagsCount: make(map[string]int), + + Categories: make(map[string]string), + CategoriesCount: make(map[string]int), + + TaggedPosts: make(map[string][]*model.MPost), + CategorizedPosts: make(map[string][]*model.MPost), + lute: lute.New(), engine: engine, } } + +// resetStorage before each update, delete the cache +func (ma *MApp) resetStorage() { + ma.Posts = nil + ma.Tags = nil + ma.Categories = nil + ma.CategoriesCount = nil + ma.TaggedPosts = nil + ma.CategorizedPosts = nil + ma.SrcFiles = nil + + ma.Tags = make(map[string]string) + ma.TagsCount = make(map[string]int) + ma.Categories = make(map[string]string) + ma.CategoriesCount = make(map[string]int) + ma.TaggedPosts = make(map[string][]*model.MPost) + ma.CategorizedPosts = make(map[string][]*model.MPost) +} diff --git a/internal/mApp/mHandler.go b/internal/mApp/mHandler.go index d1daa19..d60ebdf 100644 --- a/internal/mApp/mHandler.go +++ b/internal/mApp/mHandler.go @@ -1,6 +1,7 @@ package mApp import ( + "encoding/json" "html/template" "io" "net/http" @@ -24,6 +25,7 @@ func (ma *MApp) IndexHandler(ctx *gin.Context) { recentPosts = append(recentPosts, tmpPost) } + // return some basic information resData := gin.H{ "site_info": gin.H{ "logo": ma.Config.MSite.Info.Logo, @@ -48,6 +50,8 @@ func (ma *MApp) PostHandler(ctx *gin.Context) { var success bool var html string var realPost model.MPost + + // traverse to find the corresponding post, read its HTML file, and inject it into the template for _, post := range ma.Posts { if post.HtmlHash == postHash { file, err := os.OpenFile(post.HtmlPath, os.O_RDONLY, 0644) @@ -101,6 +105,85 @@ func (ma *MApp) PostHandler(ctx *gin.Context) { ctx.HTML(http.StatusOK, "post.html", resData) } +func (ma *MApp) TagHandler(ctx *gin.Context) { + tagHash := ctx.Param("hash") + tagName := ma.Tags[tagHash] + + // paging logic processing + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size := ma.Config.MSite.Post.Tag.Number + + var prePage, curPage, nxtPage, allPage int + allPage = (len(ma.TaggedPosts[tagHash]) + size - 1) / size + + if allPage > 0 { + if page <= 0 { + curPage = 1 + } else if page > allPage { + curPage = allPage + } else { + curPage = page + } + } else { + curPage = 0 + } + + prePage = curPage - 1 + nxtPage = curPage + 1 + + if prePage <= 0 { + prePage = curPage + } + + if nxtPage > allPage { + nxtPage = allPage + } + + // generate tagged posts + start := (curPage - 1) * size + offset := curPage * size + + var taggedPosts []model.MPost + var tagList [][]interface{} + if start >= 0 { + for i := start; i < utils.Min(len(ma.TaggedPosts[tagHash]), offset); i++ { + tmpPost := *ma.TaggedPosts[tagHash][i] + tmpPost.Date = strings.Split(tmpPost.Date, " ")[0] + taggedPosts = append(taggedPosts, tmpPost) + } + + for tag, num := range ma.TagsCount { + tagList = append(tagList, []interface{}{tag, num}) + } + } + + tagListJson, _ := json.Marshal(tagList) + resData := gin.H{ + "site_info": gin.H{ + "logo": ma.Config.MSite.Info.Logo, + "title": ma.Config.MSite.Info.Title, + "author": ma.Config.MSite.Info.Author, + "language": ma.Config.MSite.Info.Language, + "copyright": template.HTML(ma.Config.MSite.Info.Copyright), + }, + "menu": ma.Config.MSite.Menu, + "page_info": gin.H{ + "pre_page": prePage, + "cur_page": curPage, + "nxt_page": nxtPage, + "all_page": allPage, + }, + "tagged_post": gin.H{ + "posts": taggedPosts, + "tag_name": tagName, + "tag_hash": tagHash, + "tag_list": string(tagListJson), + }, + } + + ctx.HTML(http.StatusOK, "tag.html", resData) +} + func (ma *MApp) ArchiveHandler(ctx *gin.Context) { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) size := ma.Config.MSite.Post.Archive.Number @@ -170,7 +253,8 @@ func (ma *MApp) ArchiveHandler(ctx *gin.Context) { func (ma *MApp) UpdateBlogHandler(ctx *gin.Context) { var err error - + ma.resetStorage() + err = ma.loadMarkdownFiles() if err != nil { _ = ctx.Error(err) diff --git a/internal/mApp/mMarkdown.go b/internal/mApp/mMarkdown.go index 09301bc..ab7a9b7 100644 --- a/internal/mApp/mMarkdown.go +++ b/internal/mApp/mMarkdown.go @@ -101,7 +101,11 @@ func (ma *MApp) parseMarkdowns() error { Hash: tagHash, }) - ma.TaggedPosts = append(ma.TaggedPosts, &post) + ma.Tags[tagHash] = tag + ma.TagsCount[tag] += 1 + ma.TaggedPosts[tagHash] = append(ma.TaggedPosts[tagHash], &post) + + model.SortPostsByDate(ma.TaggedPosts[tagHash]) } for _, category := range post.Categories { @@ -111,7 +115,11 @@ func (ma *MApp) parseMarkdowns() error { Hash: categoryHash, }) - ma.CategorizedPosts = append(ma.CategorizedPosts, &post) + ma.Categories[categoryHash] = category + ma.CategoriesCount[category] += 1 + ma.CategorizedPosts[categoryHash] = append(ma.CategorizedPosts[categoryHash], &post) + + model.SortPostsByDate(ma.CategorizedPosts[categoryHash]) } // free the raw tag and category slice diff --git a/internal/mApp/mRouter.go b/internal/mApp/mRouter.go index 565feb2..35ef7e8 100644 --- a/internal/mApp/mRouter.go +++ b/internal/mApp/mRouter.go @@ -4,6 +4,7 @@ func (ma *MApp) loadRoutes() { ma.engine.GET("/", ma.IndexHandler) ma.engine.GET("/archive", ma.ArchiveHandler) ma.engine.GET("/post/:hash", ma.PostHandler) + ma.engine.GET("/tag/:hash", ma.TagHandler) ma.engine.PUT("/update", ma.UpdateBlogHandler) } diff --git a/templates/default/assets/css/global.css b/templates/default/assets/css/global.css index 5485eb5..a0cc94e 100644 --- a/templates/default/assets/css/global.css +++ b/templates/default/assets/css/global.css @@ -94,4 +94,11 @@ a:hover { .main-menu-link { margin-right: 10px; +} + +.special-info-area { + height: 340px; + padding: 20px; + border-radius: 4px; + border: var(--secondary-text-color) 1px dashed; } \ No newline at end of file diff --git a/templates/default/assets/css/post.css b/templates/default/assets/css/post.css index 5257bb8..1578b22 100644 --- a/templates/default/assets/css/post.css +++ b/templates/default/assets/css/post.css @@ -4,6 +4,18 @@ pre { border: var(--secondary-text-color) 1px dashed; } +table { + width: 100%; +} + +tr, th, td { + height: 30px; + padding: 10px; + border-radius: 4px; + border: 1px var(--secondary-text-color) solid; + color: var(--primary-text-color); +} + .post-menu { max-width: 1000px; } diff --git a/templates/default/assets/css/tag.css b/templates/default/assets/css/tag.css new file mode 100644 index 0000000..536578d --- /dev/null +++ b/templates/default/assets/css/tag.css @@ -0,0 +1,53 @@ +.tagged-name { + color: var(--primary-color); +} + +.tagged-post-item { + padding-top: 5px; + padding-bottom: 5px; + color: var(--primary-text-color); + list-style: none; +} + +.tagged-post-item-wrap { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.tagged-post-item-date { + color: var(--secondary-text-color); +} + +.tagged-post-item-date { + color: var(--secondary-text-color); +} + +.tagged-post-pagination-item { + color: var(--secondary-text-color); + padding-right: 10px; +} + +.tag-statistics { + width: 60%; + height: 100%; + position: fixed; + top: 0; + right: 0; +} + +.tag-word-cloud-canvas { + width: 100%; + height: 100%; + opacity: 0.3; +} + +.tag-word-cloud-canvas * { + user-select: none; +} + +@media screen and (max-width: 992px) { + .tag-statistics { + display: none; + } +} \ No newline at end of file diff --git a/templates/default/assets/js/lib/wordcloud2.js b/templates/default/assets/js/lib/wordcloud2.js new file mode 100644 index 0000000..9ed6be3 --- /dev/null +++ b/templates/default/assets/js/lib/wordcloud2.js @@ -0,0 +1,1243 @@ +/*! + * wordcloud2.js + * http://timdream.org/wordcloud2.js/ + * + * Copyright 2011 - 2019 Tim Guan-tin Chien and contributors. + * Released under the MIT license + */ + +'use strict' + +// setImmediate +if (!window.setImmediate) { + window.setImmediate = (function setupSetImmediate () { + return window.msSetImmediate || + window.webkitSetImmediate || + window.mozSetImmediate || + window.oSetImmediate || + (function setupSetZeroTimeout () { + if (!window.postMessage || !window.addEventListener) { + return null + } + + var callbacks = [undefined] + var message = 'zero-timeout-message' + + // Like setTimeout, but only takes a function argument. There's + // no time argument (always zero) and no arguments (you have to + // use a closure). + var setZeroTimeout = function setZeroTimeout (callback) { + var id = callbacks.length + callbacks.push(callback) + window.postMessage(message + id.toString(36), '*') + + return id + } + + window.addEventListener('message', function setZeroTimeoutMessage (evt) { + // Skipping checking event source, retarded IE confused this window + // object with another in the presence of iframe + if (typeof evt.data !== 'string' || + evt.data.substr(0, message.length) !== message/* || + evt.source !== window */) { + return + } + + evt.stopImmediatePropagation() + + var id = parseInt(evt.data.substr(message.length), 36) + if (!callbacks[id]) { + return + } + + callbacks[id]() + callbacks[id] = undefined + }, true) + + /* specify clearImmediate() here since we need the scope */ + window.clearImmediate = function clearZeroTimeout (id) { + if (!callbacks[id]) { + return + } + + callbacks[id] = undefined + } + + return setZeroTimeout + })() || + // fallback + function setImmediateFallback (fn) { + window.setTimeout(fn, 0) + } + })() +} + +if (!window.clearImmediate) { + window.clearImmediate = (function setupClearImmediate () { + return window.msClearImmediate || + window.webkitClearImmediate || + window.mozClearImmediate || + window.oClearImmediate || + // "clearZeroTimeout" is implement on the previous block || + // fallback + function clearImmediateFallback (timer) { + window.clearTimeout(timer) + } + })() +} + +(function (global) { + // Check if WordCloud can run on this browser + var isSupported = (function isSupported () { + var canvas = document.createElement('canvas') + if (!canvas || !canvas.getContext) { + return false + } + + var ctx = canvas.getContext('2d') + if (!ctx) { + return false + } + if (!ctx.getImageData) { + return false + } + if (!ctx.fillText) { + return false + } + + if (!Array.prototype.some) { + return false + } + if (!Array.prototype.push) { + return false + } + + return true + }()) + + // Find out if the browser impose minium font size by + // drawing small texts on a canvas and measure it's width. + var minFontSize = (function getMinFontSize () { + if (!isSupported) { + return + } + + var ctx = document.createElement('canvas').getContext('2d') + + // start from 20 + var size = 20 + + // two sizes to measure + var hanWidth, mWidth + + while (size) { + ctx.font = size.toString(10) + 'px sans-serif' + if ((ctx.measureText('\uFF37').width === hanWidth) && + (ctx.measureText('m').width) === mWidth) { + return (size + 1) + } + + hanWidth = ctx.measureText('\uFF37').width + mWidth = ctx.measureText('m').width + + size-- + } + + return 0 + })() + + var getItemExtraData = function (item) { + if (Array.isArray(item)) { + var itemCopy = item.slice() + // remove data we already have (word and weight) + itemCopy.splice(0, 2) + return itemCopy + } else { + return [] + } + } + + // Based on http://jsfromhell.com/array/shuffle + var shuffleArray = function shuffleArray (arr) { + for (var j, x, i = arr.length; i;) { + j = Math.floor(Math.random() * i) + x = arr[--i] + arr[i] = arr[j] + arr[j] = x + } + return arr + } + + var timer = {}; + var WordCloud = function WordCloud (elements, options) { + if (!isSupported) { + return + } + + var timerId = Math.floor(Math.random() * Date.now()) + + if (!Array.isArray(elements)) { + elements = [elements] + } + + elements.forEach(function (el, i) { + if (typeof el === 'string') { + elements[i] = document.getElementById(el) + if (!elements[i]) { + throw new Error('The element id specified is not found.') + } + } else if (!el.tagName && !el.appendChild) { + throw new Error('You must pass valid HTML elements, or ID of the element.') + } + }) + + /* Default values to be overwritten by options object */ + var settings = { + list: [], + fontFamily: '"Trebuchet MS", "Heiti TC", "微軟正黑體", ' + + '"Arial Unicode MS", "Droid Fallback Sans", sans-serif', + fontWeight: 'normal', + color: 'random-dark', + minSize: 0, // 0 to disable + weightFactor: 1, + clearCanvas: true, + backgroundColor: '#fff', // opaque white = rgba(255, 255, 255, 1) + + gridSize: 8, + drawOutOfBound: false, + shrinkToFit: false, + origin: null, + + drawMask: false, + maskColor: 'rgba(255,0,0,0.3)', + maskGapWidth: 0.3, + + wait: 0, + abortThreshold: 0, // disabled + abort: function noop () {}, + + minRotation: -Math.PI / 2, + maxRotation: Math.PI / 2, + rotationSteps: 0, + + shuffle: true, + rotateRatio: 0.1, + + shape: 'circle', + ellipticity: 0.65, + + classes: null, + + hover: null, + click: null + } + + if (options) { + for (var key in options) { + if (key in settings) { + settings[key] = options[key] + } + } + } + + /* Convert weightFactor into a function */ + if (typeof settings.weightFactor !== 'function') { + var factor = settings.weightFactor + settings.weightFactor = function weightFactor (pt) { + return pt * factor // in px + } + } + + /* Convert shape into a function */ + if (typeof settings.shape !== 'function') { + switch (settings.shape) { + case 'circle': + /* falls through */ + default: + // 'circle' is the default and a shortcut in the code loop. + settings.shape = 'circle' + break + + case 'cardioid': + settings.shape = function shapeCardioid (theta) { + return 1 - Math.sin(theta) + } + break + + /* + To work out an X-gon, one has to calculate "m", + where 1/(cos(2*PI/X)+m*sin(2*PI/X)) = 1/(cos(0)+m*sin(0)) + http://www.wolframalpha.com/input/?i=1%2F%28cos%282*PI%2FX%29%2Bm*sin%28 + 2*PI%2FX%29%29+%3D+1%2F%28cos%280%29%2Bm*sin%280%29%29 + Copy the solution into polar equation r = 1/(cos(t') + m*sin(t')) + where t' equals to mod(t, 2PI/X) + */ + + case 'diamond': + // http://www.wolframalpha.com/input/?i=plot+r+%3D+1%2F%28cos%28mod+ + // %28t%2C+PI%2F2%29%29%2Bsin%28mod+%28t%2C+PI%2F2%29%29%29%2C+t+%3D + // +0+..+2*PI + settings.shape = function shapeSquare (theta) { + var thetaPrime = theta % (2 * Math.PI / 4) + return 1 / (Math.cos(thetaPrime) + Math.sin(thetaPrime)) + } + break + + case 'square': + // http://www.wolframalpha.com/input/?i=plot+r+%3D+min(1%2Fabs(cos(t + // )),1%2Fabs(sin(t)))),+t+%3D+0+..+2*PI + settings.shape = function shapeSquare (theta) { + return Math.min( + 1 / Math.abs(Math.cos(theta)), + 1 / Math.abs(Math.sin(theta)) + ) + } + break + + case 'triangle-forward': + // http://www.wolframalpha.com/input/?i=plot+r+%3D+1%2F%28cos%28mod+ + // %28t%2C+2*PI%2F3%29%29%2Bsqrt%283%29sin%28mod+%28t%2C+2*PI%2F3%29 + // %29%29%2C+t+%3D+0+..+2*PI + settings.shape = function shapeTriangle (theta) { + var thetaPrime = theta % (2 * Math.PI / 3) + return 1 / (Math.cos(thetaPrime) + + Math.sqrt(3) * Math.sin(thetaPrime)) + } + break + + case 'triangle': + case 'triangle-upright': + settings.shape = function shapeTriangle (theta) { + var thetaPrime = (theta + Math.PI * 3 / 2) % (2 * Math.PI / 3) + return 1 / (Math.cos(thetaPrime) + + Math.sqrt(3) * Math.sin(thetaPrime)) + } + break + + case 'pentagon': + settings.shape = function shapePentagon (theta) { + var thetaPrime = (theta + 0.955) % (2 * Math.PI / 5) + return 1 / (Math.cos(thetaPrime) + + 0.726543 * Math.sin(thetaPrime)) + } + break + + case 'star': + settings.shape = function shapeStar (theta) { + var thetaPrime = (theta + 0.955) % (2 * Math.PI / 10) + if ((theta + 0.955) % (2 * Math.PI / 5) - (2 * Math.PI / 10) >= 0) { + return 1 / (Math.cos((2 * Math.PI / 10) - thetaPrime) + + 3.07768 * Math.sin((2 * Math.PI / 10) - thetaPrime)) + } else { + return 1 / (Math.cos(thetaPrime) + + 3.07768 * Math.sin(thetaPrime)) + } + } + break + } + } + + /* Make sure gridSize is a whole number and is not smaller than 4px */ + settings.gridSize = Math.max(Math.floor(settings.gridSize), 4) + + /* shorthand */ + var g = settings.gridSize + var maskRectWidth = g - settings.maskGapWidth + + /* normalize rotation settings */ + var rotationRange = Math.abs(settings.maxRotation - settings.minRotation) + var rotationSteps = Math.abs(Math.floor(settings.rotationSteps)) + var minRotation = Math.min(settings.maxRotation, settings.minRotation) + + /* information/object available to all functions, set when start() */ + var grid, // 2d array containing filling information + ngx, ngy, // width and height of the grid + center, // position of the center of the cloud + maxRadius + + /* timestamp for measuring each putWord() action */ + var escapeTime + + /* function for getting the color of the text */ + var getTextColor + function randomHslColor (min, max) { + return 'hsl(' + + (Math.random() * 360).toFixed() + ',' + + (Math.random() * 30 + 70).toFixed() + '%,' + + (Math.random() * (max - min) + min).toFixed() + '%)' + } + switch (settings.color) { + case 'random-dark': + getTextColor = function getRandomDarkColor () { + return randomHslColor(10, 50) + } + break + + case 'random-light': + getTextColor = function getRandomLightColor () { + return randomHslColor(50, 90) + } + break + + default: + if (typeof settings.color === 'function') { + getTextColor = settings.color + } + break + } + + /* function for getting the font-weight of the text */ + var getTextFontWeight + if (typeof settings.fontWeight === 'function') { + getTextFontWeight = settings.fontWeight + } + + /* function for getting the classes of the text */ + var getTextClasses = null + if (typeof settings.classes === 'function') { + getTextClasses = settings.classes + } + + /* Interactive */ + var interactive = false + var infoGrid = [] + var hovered + + var getInfoGridFromMouseTouchEvent = + function getInfoGridFromMouseTouchEvent (evt) { + var canvas = evt.currentTarget + var rect = canvas.getBoundingClientRect() + var clientX + var clientY + /** Detect if touches are available */ + if (evt.touches) { + clientX = evt.touches[0].clientX + clientY = evt.touches[0].clientY + } else { + clientX = evt.clientX + clientY = evt.clientY + } + + var eventXvalue = clientX - rect.left + var eventX = eventXvalue < 0 ? 0 : eventXvalue + var eventY = clientY - rect.top + + var x = Math.floor(eventX * ((canvas.width / rect.width) || 1) / g) + var y = Math.floor(eventY * ((canvas.height / rect.height) || 1) / g) + + if (!infoGrid[x]) { + return null + } + + return infoGrid[x][y] + } + + var wordcloudhover = function wordcloudhover (evt) { + var info = getInfoGridFromMouseTouchEvent(evt) + + if (hovered === info) { + return + } + + hovered = info + if (!info) { + settings.hover(undefined, undefined, evt) + + return + } + + settings.hover(info.item, info.dimension, evt) + } + + var wordcloudclick = function wordcloudclick (evt) { + var info = getInfoGridFromMouseTouchEvent(evt) + if (!info) { + return + } + + settings.click(info.item, info.dimension, evt) + evt.preventDefault() + } + + /* Get points on the grid for a given radius away from the center */ + var pointsAtRadius = [] + var getPointsAtRadius = function getPointsAtRadius (radius) { + if (pointsAtRadius[radius]) { + return pointsAtRadius[radius] + } + + // Look for these number of points on each radius + var T = radius * 8 + + // Getting all the points at this radius + var t = T + var points = [] + + if (radius === 0) { + points.push([center[0], center[1], 0]) + } + + while (t--) { + // distort the radius to put the cloud in shape + var rx = 1 + if (settings.shape !== 'circle') { + rx = settings.shape(t / T * 2 * Math.PI) // 0 to 1 + } + + // Push [x, y, t] t is used solely for getTextColor() + points.push([ + center[0] + radius * rx * Math.cos(-t / T * 2 * Math.PI), + center[1] + radius * rx * Math.sin(-t / T * 2 * Math.PI) * + settings.ellipticity, + t / T * 2 * Math.PI]) + } + + pointsAtRadius[radius] = points + return points + } + + /* Return true if we had spent too much time */ + var exceedTime = function exceedTime () { + return ((settings.abortThreshold > 0) && + ((new Date()).getTime() - escapeTime > settings.abortThreshold)) + } + + /* Get the deg of rotation according to settings, and luck. */ + var getRotateDeg = function getRotateDeg () { + if (settings.rotateRatio === 0) { + return 0 + } + + if (Math.random() > settings.rotateRatio) { + return 0 + } + + if (rotationRange === 0) { + return minRotation + } + + if (rotationSteps > 0) { + // Min rotation + zero or more steps * span of one step + return minRotation + + Math.floor(Math.random() * rotationSteps) * + rotationRange / (rotationSteps - 1) + } else { + return minRotation + Math.random() * rotationRange + } + } + + var getTextInfo = function getTextInfo (word, weight, rotateDeg, extraDataArray) { + // calculate the acutal font size + // fontSize === 0 means weightFactor function wants the text skipped, + // and size < minSize means we cannot draw the text. + var debug = false + var fontSize = settings.weightFactor(weight) + if (fontSize <= settings.minSize) { + return false + } + + // Scale factor here is to make sure fillText is not limited by + // the minium font size set by browser. + // It will always be 1 or 2n. + var mu = 1 + if (fontSize < minFontSize) { + mu = (function calculateScaleFactor () { + var mu = 2 + while (mu * fontSize < minFontSize) { + mu += 2 + } + return mu + })() + } + + // Get fontWeight that will be used to set fctx.font + var fontWeight + if (getTextFontWeight) { + fontWeight = getTextFontWeight(word, weight, fontSize, extraDataArray) + } else { + fontWeight = settings.fontWeight + } + + var fcanvas = document.createElement('canvas') + var fctx = fcanvas.getContext('2d', { willReadFrequently: true }) + + fctx.font = fontWeight + ' ' + + (fontSize * mu).toString(10) + 'px ' + settings.fontFamily + + // Estimate the dimension of the text with measureText(). + var fw = fctx.measureText(word).width / mu + var fh = Math.max(fontSize * mu, + fctx.measureText('m').width, + fctx.measureText('\uFF37').width + ) / mu + + // Create a boundary box that is larger than our estimates, + // so text don't get cut of (it sill might) + var boxWidth = fw + fh * 2 + var boxHeight = fh * 3 + var fgw = Math.ceil(boxWidth / g) + var fgh = Math.ceil(boxHeight / g) + boxWidth = fgw * g + boxHeight = fgh * g + + // Calculate the proper offsets to make the text centered at + // the preferred position. + + // This is simply half of the width. + var fillTextOffsetX = -fw / 2 + // Instead of moving the box to the exact middle of the preferred + // position, for Y-offset we move 0.4 instead, so Latin alphabets look + // vertical centered. + var fillTextOffsetY = -fh * 0.4 + + // Calculate the actual dimension of the canvas, considering the rotation. + var cgh = Math.ceil((boxWidth * Math.abs(Math.sin(rotateDeg)) + + boxHeight * Math.abs(Math.cos(rotateDeg))) / g) + var cgw = Math.ceil((boxWidth * Math.abs(Math.cos(rotateDeg)) + + boxHeight * Math.abs(Math.sin(rotateDeg))) / g) + var width = cgw * g + var height = cgh * g + + fcanvas.setAttribute('width', width) + fcanvas.setAttribute('height', height) + + if (debug) { + // Attach fcanvas to the DOM + document.body.appendChild(fcanvas) + // Save it's state so that we could restore and draw the grid correctly. + fctx.save() + } + + // Scale the canvas with |mu|. + fctx.scale(1 / mu, 1 / mu) + fctx.translate(width * mu / 2, height * mu / 2) + fctx.rotate(-rotateDeg) + + // Once the width/height is set, ctx info will be reset. + // Set it again here. + fctx.font = fontWeight + ' ' + + (fontSize * mu).toString(10) + 'px ' + settings.fontFamily + + // Fill the text into the fcanvas. + // XXX: We cannot because textBaseline = 'top' here because + // Firefox and Chrome uses different default line-height for canvas. + // Please read https://bugzil.la/737852#c6. + // Here, we use textBaseline = 'middle' and draw the text at exactly + // 0.5 * fontSize lower. + fctx.fillStyle = '#000' + fctx.textBaseline = 'middle' + fctx.fillText( + word, fillTextOffsetX * mu, + (fillTextOffsetY + fontSize * 0.5) * mu + ) + + // Get the pixels of the text + var imageData = fctx.getImageData(0, 0, width, height).data + + if (exceedTime()) { + return false + } + + if (debug) { + // Draw the box of the original estimation + fctx.strokeRect( + fillTextOffsetX * mu, + fillTextOffsetY, fw * mu, fh * mu + ) + fctx.restore() + } + + // Read the pixels and save the information to the occupied array + var occupied = [] + var gx = cgw + var gy, x, y + var bounds = [cgh / 2, cgw / 2, cgh / 2, cgw / 2] + while (gx--) { + gy = cgh + while (gy--) { + y = g + /* eslint no-labels: ["error", { "allowLoop": true }] */ + singleGridLoop: while (y--) { + x = g + while (x--) { + if (imageData[((gy * g + y) * width + + (gx * g + x)) * 4 + 3]) { + occupied.push([gx, gy]) + + if (gx < bounds[3]) { + bounds[3] = gx + } + if (gx > bounds[1]) { + bounds[1] = gx + } + if (gy < bounds[0]) { + bounds[0] = gy + } + if (gy > bounds[2]) { + bounds[2] = gy + } + + if (debug) { + fctx.fillStyle = 'rgba(255, 0, 0, 0.5)' + fctx.fillRect(gx * g, gy * g, g - 0.5, g - 0.5) + } + break singleGridLoop + } + } + } + if (debug) { + fctx.fillStyle = 'rgba(0, 0, 255, 0.5)' + fctx.fillRect(gx * g, gy * g, g - 0.5, g - 0.5) + } + } + } + + if (debug) { + fctx.fillStyle = 'rgba(0, 255, 0, 0.5)' + fctx.fillRect( + bounds[3] * g, + bounds[0] * g, + (bounds[1] - bounds[3] + 1) * g, + (bounds[2] - bounds[0] + 1) * g + ) + } + + // Return information needed to create the text on the real canvas + return { + mu: mu, + occupied: occupied, + bounds: bounds, + gw: cgw, + gh: cgh, + fillTextOffsetX: fillTextOffsetX, + fillTextOffsetY: fillTextOffsetY, + fillTextWidth: fw, + fillTextHeight: fh, + fontSize: fontSize + } + } + + /* Determine if there is room available in the given dimension */ + var canFitText = function canFitText (gx, gy, gw, gh, occupied) { + // Go through the occupied points, + // return false if the space is not available. + var i = occupied.length + while (i--) { + var px = gx + occupied[i][0] + var py = gy + occupied[i][1] + + if (px >= ngx || py >= ngy || px < 0 || py < 0) { + if (!settings.drawOutOfBound) { + return false + } + continue + } + + if (!grid[px][py]) { + return false + } + } + return true + } + + /* Actually draw the text on the grid */ + var drawText = function drawText (gx, gy, info, word, weight, distance, theta, rotateDeg, attributes, extraDataArray) { + var fontSize = info.fontSize + var color + if (getTextColor) { + color = getTextColor(word, weight, fontSize, distance, theta, extraDataArray) + } else { + color = settings.color + } + + // get fontWeight that will be used to set ctx.font and font style rule + var fontWeight + if (getTextFontWeight) { + fontWeight = getTextFontWeight(word, weight, fontSize, extraDataArray) + } else { + fontWeight = settings.fontWeight + } + + var classes + if (getTextClasses) { + classes = getTextClasses(word, weight, fontSize, extraDataArray) + } else { + classes = settings.classes + } + + elements.forEach(function (el) { + if (el.getContext) { + var ctx = el.getContext('2d') + var mu = info.mu + + // Save the current state before messing it + ctx.save() + ctx.scale(1 / mu, 1 / mu) + + ctx.font = fontWeight + ' ' + + (fontSize * mu).toString(10) + 'px ' + settings.fontFamily + ctx.fillStyle = color + + // Translate the canvas position to the origin coordinate of where + // the text should be put. + ctx.translate( + (gx + info.gw / 2) * g * mu, + (gy + info.gh / 2) * g * mu + ) + + if (rotateDeg !== 0) { + ctx.rotate(-rotateDeg) + } + + // Finally, fill the text. + + // XXX: We cannot because textBaseline = 'top' here because + // Firefox and Chrome uses different default line-height for canvas. + // Please read https://bugzil.la/737852#c6. + // Here, we use textBaseline = 'middle' and draw the text at exactly + // 0.5 * fontSize lower. + ctx.textBaseline = 'middle' + ctx.fillText( + word, info.fillTextOffsetX * mu, + (info.fillTextOffsetY + fontSize * 0.5) * mu + ) + + // The below box is always matches how s are positioned + /* ctx.strokeRect(info.fillTextOffsetX, info.fillTextOffsetY, + info.fillTextWidth, info.fillTextHeight) */ + + // Restore the state. + ctx.restore() + } else { + // drawText on DIV element + var span = document.createElement('span') + var transformRule = '' + transformRule = 'rotate(' + (-rotateDeg / Math.PI * 180) + 'deg) ' + if (info.mu !== 1) { + transformRule += + 'translateX(-' + (info.fillTextWidth / 4) + 'px) ' + + 'scale(' + (1 / info.mu) + ')' + } + var styleRules = { + position: 'absolute', + display: 'block', + font: fontWeight + ' ' + + (fontSize * info.mu) + 'px ' + settings.fontFamily, + left: ((gx + info.gw / 2) * g + info.fillTextOffsetX) + 'px', + top: ((gy + info.gh / 2) * g + info.fillTextOffsetY) + 'px', + width: info.fillTextWidth + 'px', + height: info.fillTextHeight + 'px', + lineHeight: fontSize + 'px', + whiteSpace: 'nowrap', + transform: transformRule, + webkitTransform: transformRule, + msTransform: transformRule, + transformOrigin: '50% 40%', + webkitTransformOrigin: '50% 40%', + msTransformOrigin: '50% 40%' + } + if (color) { + styleRules.color = color + } + span.textContent = word + for (var cssProp in styleRules) { + span.style[cssProp] = styleRules[cssProp] + } + if (attributes) { + for (var attribute in attributes) { + span.setAttribute(attribute, attributes[attribute]) + } + } + if (classes) { + span.className += classes + } + el.appendChild(span) + } + }) + } + + /* Help function to updateGrid */ + var fillGridAt = function fillGridAt (x, y, drawMask, dimension, item) { + if (x >= ngx || y >= ngy || x < 0 || y < 0) { + return + } + + grid[x][y] = false + + if (drawMask) { + var ctx = elements[0].getContext('2d') + ctx.fillRect(x * g, y * g, maskRectWidth, maskRectWidth) + } + + if (interactive) { + infoGrid[x][y] = { item: item, dimension: dimension } + } + } + + /* Update the filling information of the given space with occupied points. + Draw the mask on the canvas if necessary. */ + var updateGrid = function updateGrid (gx, gy, gw, gh, info, item) { + var occupied = info.occupied + var drawMask = settings.drawMask + var ctx + if (drawMask) { + ctx = elements[0].getContext('2d') + ctx.save() + ctx.fillStyle = settings.maskColor + } + + var dimension + if (interactive) { + var bounds = info.bounds + dimension = { + x: (gx + bounds[3]) * g, + y: (gy + bounds[0]) * g, + w: (bounds[1] - bounds[3] + 1) * g, + h: (bounds[2] - bounds[0] + 1) * g + } + } + + var i = occupied.length + while (i--) { + var px = gx + occupied[i][0] + var py = gy + occupied[i][1] + + if (px >= ngx || py >= ngy || px < 0 || py < 0) { + continue + } + + fillGridAt(px, py, drawMask, dimension, item) + } + + if (drawMask) { + ctx.restore() + } + } + + /* putWord() processes each item on the list, + calculate it's size and determine it's position, and actually + put it on the canvas. */ + var putWord = function putWord (item) { + var word, weight, attributes + if (Array.isArray(item)) { + word = item[0] + weight = item[1] + } else { + word = item.word + weight = item.weight + attributes = item.attributes + } + var rotateDeg = getRotateDeg() + + var extraDataArray = getItemExtraData(item) + + // get info needed to put the text onto the canvas + var info = getTextInfo(word, weight, rotateDeg, extraDataArray) + + // not getting the info means we shouldn't be drawing this one. + if (!info) { + return false + } + + if (exceedTime()) { + return false + } + + // If drawOutOfBound is set to false, + // skip the loop if we have already know the bounding box of + // word is larger than the canvas. + if (!settings.drawOutOfBound && !settings.shrinkToFit) { + var bounds = info.bounds; + if ((bounds[1] - bounds[3] + 1) > ngx || + (bounds[2] - bounds[0] + 1) > ngy) { + return false + } + } + + // Determine the position to put the text by + // start looking for the nearest points + var r = maxRadius + 1 + + var tryToPutWordAtPoint = function (gxy) { + var gx = Math.floor(gxy[0] - info.gw / 2) + var gy = Math.floor(gxy[1] - info.gh / 2) + var gw = info.gw + var gh = info.gh + + // If we cannot fit the text at this position, return false + // and go to the next position. + if (!canFitText(gx, gy, gw, gh, info.occupied)) { + return false + } + + // Actually put the text on the canvas + drawText(gx, gy, info, word, weight, + (maxRadius - r), gxy[2], rotateDeg, attributes, extraDataArray) + + // Mark the spaces on the grid as filled + updateGrid(gx, gy, gw, gh, info, item) + + // Return true so some() will stop and also return true. + return true + } + + while (r--) { + var points = getPointsAtRadius(maxRadius - r) + + if (settings.shuffle) { + points = [].concat(points) + shuffleArray(points) + } + + // Try to fit the words by looking at each point. + // array.some() will stop and return true + // when putWordAtPoint() returns true. + // If all the points returns false, array.some() returns false. + var drawn = points.some(tryToPutWordAtPoint) + + if (drawn) { + // leave putWord() and return true + return true + } + } + if (settings.shrinkToFit) { + if (Array.isArray(item)) { + item[1] = item[1] * 3 / 4 + } else { + item.weight = item.weight * 3 / 4 + } + return putWord(item) + } + // we tried all distances but text won't fit, return false + return false + } + + /* Send DOM event to all elements. Will stop sending event and return + if the previous one is canceled (for cancelable events). */ + var sendEvent = function sendEvent (type, cancelable, details) { + if (cancelable) { + return !elements.some(function (el) { + var event = new CustomEvent(type, { + detail: details || {} + }) + return !el.dispatchEvent(event) + }, this) + } else { + elements.forEach(function (el) { + var event = new CustomEvent(type, { + detail: details || {} + }) + el.dispatchEvent(event) + }, this) + } + } + + /* Start drawing on a canvas */ + var start = function start () { + // For dimensions, clearCanvas etc., + // we only care about the first element. + var canvas = elements[0] + + if (canvas.getContext) { + ngx = Math.ceil(canvas.width / g) + ngy = Math.ceil(canvas.height / g) + } else { + var rect = canvas.getBoundingClientRect() + ngx = Math.ceil(rect.width / g) + ngy = Math.ceil(rect.height / g) + } + + // Sending a wordcloudstart event which cause the previous loop to stop. + // Do nothing if the event is canceled. + if (!sendEvent('wordcloudstart', true)) { + return + } + + // Determine the center of the word cloud + center = (settings.origin) + ? [settings.origin[0] / g, settings.origin[1] / g] + : [ngx / 2, ngy / 2] + + // Maxium radius to look for space + maxRadius = Math.floor(Math.sqrt(ngx * ngx + ngy * ngy)) + + /* Clear the canvas only if the clearCanvas is set, + if not, update the grid to the current canvas state */ + grid = [] + + var gx, gy, i + if (!canvas.getContext || settings.clearCanvas) { + elements.forEach(function (el) { + if (el.getContext) { + var ctx = el.getContext('2d') + ctx.fillStyle = settings.backgroundColor + ctx.clearRect(0, 0, ngx * (g + 1), ngy * (g + 1)) + ctx.fillRect(0, 0, ngx * (g + 1), ngy * (g + 1)) + } else { + el.textContent = '' + el.style.backgroundColor = settings.backgroundColor + el.style.position = 'relative' + } + }) + + /* fill the grid with empty state */ + gx = ngx + while (gx--) { + grid[gx] = [] + gy = ngy + while (gy--) { + grid[gx][gy] = true + } + } + } else { + /* Determine bgPixel by creating + another canvas and fill the specified background color. */ + var bctx = document.createElement('canvas').getContext('2d') + + bctx.fillStyle = settings.backgroundColor + bctx.fillRect(0, 0, 1, 1) + var bgPixel = bctx.getImageData(0, 0, 1, 1).data + + /* Read back the pixels of the canvas we got to tell which part of the + canvas is empty. + (no clearCanvas only works with a canvas, not divs) */ + var imageData = + canvas.getContext('2d').getImageData(0, 0, ngx * g, ngy * g).data + + gx = ngx + var x, y + while (gx--) { + grid[gx] = [] + gy = ngy + while (gy--) { + y = g + /* eslint no-labels: ["error", { "allowLoop": true }] */ + singleGridLoop: while (y--) { + x = g + while (x--) { + i = 4 + while (i--) { + if (imageData[((gy * g + y) * ngx * g + + (gx * g + x)) * 4 + i] !== bgPixel[i]) { + grid[gx][gy] = false + break singleGridLoop + } + } + } + } + if (grid[gx][gy] !== false) { + grid[gx][gy] = true + } + } + } + + imageData = bctx = bgPixel = undefined + } + + // fill the infoGrid with empty state if we need it + if (settings.hover || settings.click) { + interactive = true + + /* fill the grid with empty state */ + gx = ngx + 1 + while (gx--) { + infoGrid[gx] = [] + } + + if (settings.hover) { + canvas.addEventListener('mousemove', wordcloudhover) + } + + if (settings.click) { + canvas.addEventListener('click', wordcloudclick) + canvas.style.webkitTapHighlightColor = 'rgba(0, 0, 0, 0)' + } + + canvas.addEventListener('wordcloudstart', function stopInteraction () { + canvas.removeEventListener('wordcloudstart', stopInteraction) + canvas.removeEventListener('mousemove', wordcloudhover) + canvas.removeEventListener('click', wordcloudclick) + hovered = undefined + }) + } + + i = 0 + var loopingFunction, stoppingFunction + if (settings.wait !== 0) { + loopingFunction = window.setTimeout + stoppingFunction = window.clearTimeout + } else { + loopingFunction = window.setImmediate + stoppingFunction = window.clearImmediate + } + + var addEventListener = function addEventListener (type, listener) { + elements.forEach(function (el) { + el.addEventListener(type, listener) + }, this) + } + + var removeEventListener = function removeEventListener (type, listener) { + elements.forEach(function (el) { + el.removeEventListener(type, listener) + }, this) + } + + var anotherWordCloudStart = function anotherWordCloudStart () { + removeEventListener('wordcloudstart', anotherWordCloudStart) + stoppingFunction(timer[timerId]) + } + + addEventListener('wordcloudstart', anotherWordCloudStart) + timer[timerId] = loopingFunction(function loop () { + if (i >= settings.list.length) { + stoppingFunction(timer[timerId]) + sendEvent('wordcloudstop', false) + removeEventListener('wordcloudstart', anotherWordCloudStart) + delete timer[timerId]; + return + } + escapeTime = (new Date()).getTime() + var drawn = putWord(settings.list[i]) + var canceled = !sendEvent('wordclouddrawn', true, { + item: settings.list[i], + drawn: drawn + }) + if (exceedTime() || canceled) { + stoppingFunction(timer[timerId]) + settings.abort() + sendEvent('wordcloudabort', false) + sendEvent('wordcloudstop', false) + removeEventListener('wordcloudstart', anotherWordCloudStart) + delete timer[timerId] + return + } + i++ + timer[timerId] = loopingFunction(loop, settings.wait) + }, settings.wait) + } + + // All set, start the drawing + start() + } + + WordCloud.isSupported = isSupported + WordCloud.minFontSize = minFontSize + WordCloud.stop = function stop () { + if (timer) { + for (var timerId in timer) { + window.clearImmediate(timer[timerId]) + } + } + } + + // Expose the library as an AMD module + if (typeof define === 'function' && define.amd) { // eslint-disable-line no-undef + global.WordCloud = WordCloud + define('wordcloud', [], function () { return WordCloud }) // eslint-disable-line no-undef + } else if (typeof module !== 'undefined' && module.exports) { // eslint-disable-line no-undef + module.exports = WordCloud // eslint-disable-line no-undef + } else { + global.WordCloud = WordCloud + } +})(this) // jshint ignore:line diff --git a/templates/default/assets/js/tag.js b/templates/default/assets/js/tag.js new file mode 100644 index 0000000..4dab4ef --- /dev/null +++ b/templates/default/assets/js/tag.js @@ -0,0 +1,12 @@ +const tagWordCloudCanvas = document.getElementById('tag-word-cloud-canvas'); + +const options = { + list: JSON.parse(tagList), + weightFactor: 20, + backgroundColor: 'transparent', + click: (item) => { + console.log(item[0], item[1]); + } +}; + +WordCloud(tagWordCloudCanvas, options); \ No newline at end of file diff --git a/templates/default/html/archive.html b/templates/default/html/archive.html index 96120b8..3a4ac44 100644 --- a/templates/default/html/archive.html +++ b/templates/default/html/archive.html @@ -45,7 +45,7 @@

# {{ .history_post.title }}

-
+
{{ range $i, $v := .history_post.posts }}
@@ -55,7 +55,7 @@ {{ $v.Title }}
-
+
[ {{ range $i2, $v2 := $v.TagHashes }} @@ -71,6 +71,27 @@ {{ end }}
+ + + +
diff --git a/templates/default/html/index.html b/templates/default/html/index.html index 085f85c..1b562b9 100644 --- a/templates/default/html/index.html +++ b/templates/default/html/index.html @@ -44,7 +44,7 @@

# {{ .recent_post.title }}

-
+
{{ range $i, $v := .recent_post.posts }}
@@ -54,7 +54,7 @@ {{ $v.Title }}
-
+
[ {{ range $i2, $v2 := $v.TagHashes }} @@ -70,6 +70,27 @@ {{ end }}
+ + + +
diff --git a/templates/default/html/tag.html b/templates/default/html/tag.html new file mode 100644 index 0000000..f53ba9f --- /dev/null +++ b/templates/default/html/tag.html @@ -0,0 +1,104 @@ +{{ define "tag.html" }} + + + + + + {{ .site_info.title }} + + + + + + + + + + + +
+ +
+
+ +
+
+ + +
+
+
+ {{ range $i, $v := .menu.Items }} + + {{ $v.Name }} + {{ end }} +
+
+
+ + +
+
+

# {{ .tagged_post.tag_name }}

+
+
+ {{ range $i, $v := .tagged_post.posts }} +
+
+ {{ $v.Date }}  |   + + {{ $v.Title }} + +
+
+ {{ end }} +
+
+
+ +
+
+
+ + + < + + + + {{ .page_info.cur_page }}   /   {{ .page_info.all_page }} + + + + > + + +
+
+
+ +
+
+ + +
+
+
+ + + + + + + + + +{{ end }} \ No newline at end of file