canvas实战练习:好玩的文字特效与图片处理

一、文字特效部分目录及效果图预览
二、图片处理部分都使用了下面这张

一、文字特效部分目录及效果图预览

  • 1.阴影文字


    阴影文字效果图.png
  • 2.3d拉影文字


    3d拉影文字效果图.png
  • 3.空心文字


    空心文字效果图.png
  • 4.线性渐变文字


    线性渐变文字效果图.png
  • 5.图片填充文字


    图片填充文字效果图.png
  • 6.涂鸦及将涂鸦图标保存到本地
    可以创建一个1000px*600px的画布,使用鼠标进行随意涂鸦,同时还可以点击将所绘制的画布以png图片的形式下载到本地。

目录1-5的html结构都是统一的

<p>
    <button id="btn">创建涂鸦</button>
</p>
<canvas id="canvas" width="1000" height="600"></canvas>

js部分我们都是先获取点击按钮,然后给点击按钮绑定generate事件,所有的逻辑代码都是写在generate事件函数中

var btn = document.getElementById("btn")
btn.onclick = generate
var canvas = document.getElementById("canvas")
// 获取绘图环境,参数目前都是2d
var ctx = canvas.getContext('2d')
function generate() {
  
}

1.阴影文字

function generate() {
    // 阴影颜色
    ctx.shadowColor = 'gray'
    // 阴影相对于文字的水平距离
    ctx.shadowOffsetX = 10
    // 阴影相对于文字的垂直距离
    ctx.shadowOffsetY = 10
    // 模糊效果
    ctx.shadowBlur = 20
    ctx.font = '100px Arail'
    // 100和200就是我们绘制出来的图形相对于画布的x坐标和y坐标
    ctx.fillText('HELLO WORLD', 100, 200)
}

2.3d拉影文字

拉影文字通过多次循环绘制文字阴影,同时通过偏移量和透明度的渐变,来实现3d效果

function generate() {
    // 填充背景颜色
    ctx.fillStyle = 'white'
    // 画矩形,第一第二个参数是画笔起始点的x坐标和y坐标
    // 第三第四个参数是宽度和高度
    ctx.fillRect(0, 0, 800, 600)
    ctx.font = '100px Arial'
    for (var i = 0; i < 5; i++) {
        ctx.fillStyle = 'gray'
        ctx.fillText('HELLO WORLD', 100, 200)
        ctx.shadowColor = 'rgba(0,0,0,' + (5 - i) / 5 + ')'
        ctx.shadowOffsetX = i + 4
        ctx.shadowOffsetY = i * 4
        ctx.shadowBlur = i * 4
    }
}

3.空心文字

function generate() {
    ctx.font = '100px Arail'
    // 设置边线的颜色
    ctx.strokeStyle = 'green'
    // 设置空心文字的内容
    ctx.strokeText('HELLO WORLD',100,200)
}

4.线性渐变文字

function generate() {
    ctx.font = '100px Arail'
    // 首先创建canvas提供的线性渐变的对象
    // 第一第二个参数是渐变起点的x坐标和y坐标
    // 第三第四个参数是渐变终点的x坐标和y坐标
    var ctxGradient = ctx.createLinearGradient(100, 200, 600, 200)
    // 第一个参数0代表的起点,1代表终点
    // 第二个参数代表的是颜色
    ctxGradient.addColorStop(0, 'red')
    ctxGradient.addColorStop(.5, 'yellow')
    ctxGradient.addColorStop(1, 'blue')
    // 填充颜色
    ctx.fillStyle = ctxGradient
    // 写入文字
    ctx.fillText('HELLO WORLD', 100, 200)
}

5.图片填充文字

function generate() {
    // 在canvas中创建图片对象
    ctxImg = new Image()
    // 设置图片链接
    ctxImg.src = 'https://t8.baidu.com/it/u=2247852322,986532796&fm=79&app=86&size=h300&n=0&g=4n&f=jpeg?sec=1596697694&t=d1cdff4144a92fd685442fbdd7cb7fc9'
    // 加载图片完成后会触发onload回调函数
    ctxImg.onload = function () {
        // 抓取图片准备使用,第二个参数有四个选择
        // repeat重复,repeat-x只重复x方向,repeat-y只重复y方向,no-repeat不重复
        var ctxPattern = ctx.createPattern(ctxImg, 'repeat')
        // 使用图片进行填充
        ctx.fillStyle = ctxPattern
        ctx.font = '100px Arail'
        ctx.fillText('HELLO WORLD', 100, 200)
    }
}

6.涂鸦及将涂鸦图标保存到本地

html结构代码

<p>
    <button id="btn">创建涂鸦</button>
    <button id="btn2">保存涂鸦到本体</button>
</p>
<canvas id="canvas" width="1000" height="600"></canvas>

js代码

var btn = document.getElementById("btn")
btn.onclick = generate
var canvas = document.getElementById("canvas")
var ctx = canvas.getContext('2d')
function generate() {
    // 先把画布填充上颜色,明确可以进行涂鸦的区域
    ctx.fillStyle = 'skyblue'
    ctx.fillRect(0, 0, 1000, 600)
    // 涂鸦从鼠标点击下去开始
    canvas.onmousedown = function (e) {
        var x = e.clientX - canvas.offsetLeft
        var y = e.clientY - canvas.offsetTop
        // 告诉浏览器我们要重复开始一段绘制
        ctx.beginPath()
        // 移动开始绘制的画笔起点
        ctx.moveTo(x, y)
        canvas.onmousemove = function (e) {
            var mx = e.clientX - canvas.offsetLeft
            var my = e.clientY - canvas.offsetTop
            ctx.strokeStyle = 'red'
            ctx.lineWidth = 5
            // 移动的过程中绘点连线
            ctx.lineTo(mx, my)
            // 一定要调用下这个方法,路径才能绘制出来
            ctx.stroke()
        }
        canvas.onmouseup = function (e) {
            canvas.onmousemove = null
        }
    }
}
var btn2 = document.getElementById("btn2")
btn2.onclick = handleImg
function handleImg() {
    // 第一个参数是要保存的图片格式,第二个参数是文件质量,1为最高质量
    var imgData = canvas.toDataURL('image/png', 1)
    // 拿到的数据含有base64编码形式的图片资源
    console.log(imgData)
    var a = document.createElement('a')
    a.href = imgData
    // h5中的a标签可以提供直接点击下载的download属性,属性值就是下载的文件的默认名称
    a.download = 'imgData'
    // 模拟点击a标签,触发下载效果
    a.click()
}

二、图片处理部分都使用了下面这张

原始图片效果图.png
  • 1.底片效果


    底片效果.png
  • 2.灰度效果


    灰度效果.png
  • 3.黑白效果


    黑白效果.png
  • 4.模糊效果


    模糊效果.png
  • 5.马赛克效果


    马赛克效果.png
  • 6.毛玻璃效果


    毛玻璃效果.png
  • 7.浮雕效果


    浮雕效果.png
  • 8.水平镜像效果


    水平镜像效果.png
  • 9.刮刮乐
    创建一个灰色的画布,点击鼠标开始绘制,漏出下面的底图,模拟刮奖效果


    刮刮乐.png
  • 10.绘制饼图裁剪图片


    绘制饼图裁剪图片.png

目录1-8的html结构代码都是统一的

<p>
    <button id="btn">显示图片</button>
    <button id="btn2">处理成xx效果</button>
</p>
<canvas id="canvas" width="1000" height="600"></canvas>

js部分我们都是先获取点击按钮btn和btn2,然后分别绑定generate事件和handleImg事件,所有的逻辑代码都是写在这两个事件函数中,其中generate函数都是将图片绘制到canvas画布中

var btn = document.getElementById("btn")
btn.onclick = generate
function generate(){
  var img = new Image()
  img.src = '../demo1.webp'
  img.onload = function (e) {
      // 将图片绘制到canvas画布上面
      // 第一个参数是图片路径
      // 第二第三是图片在canvas画布中的起始x和y位置
      // 第四个第五个参数是图片的宽和高
      ctx.drawImage(img, 0, 0, 1000, 600)
  }
}
var canvas = document.getElementById("canvas")
var ctx = canvas.getContext('2d')

var btn2 = document.getElementById("btn2")
btn2.onclick = handleImg
function handleImg(){

}

1.底片效果

底片效果就是拿到每一个像素点,把rgb的值逐个取出来,然后被255减去,重置复制色值。
这里需要学习一下创建本地服务器的前置知识,因为canvas是不允许跨域加载资源的

function handleImg() {
    // 第一第二参数是拿图片数据的起始x和y
    // 第三第四参数是拿到图片的宽和高
    // 下面这行代码的完整意思就是我们要从canvas中拿一个图片,
    // 图片从画布的(0,0)位置开始,宽为1000,高600
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    console.log(imgData)
    for (var i = 0; i < imgData.data.length; i += 4) {
        imgData.data[i] = 255 - imgData.data[i]
        imgData.data[i + 1] = 255 - imgData.data[i + 1]
        imgData.data[i + 2] = 255 - imgData.data[i + 2]
        // 下面这个代表着rgba里面a,我们直接给设置成不透明就可以了
        imgData.data[i + 3] = 255
    }
    // 重新设置一下canvas画布的对应位置的图片
    // 第二第三个参数是设置的在画布上的起始点的坐标x和y
    ctx.putImageData(imgData, 0, 0)
}

这个时候打印的话,我们会发现了报错了,报错信息大致就是图片请求跨域了
因为我们现在的资源是在本地路径下。
所以我们可以通过node全局安装一下http-server,
npm i http-server -g
然后把这个项目的根目录下运行指令 http-server,就可以把项目变成了
服务器运行的模式,通过对应的端口就可以对项目进行访问了

2.灰度效果

灰度效果就是取每个像素点的rgb,将三个数值取平均数,然后将平均数赋值给对应的色值。

function handleImg() {
    // 第一第二参数是拿图片数据的起始x和y
    // 第三第四参数是拿到图片的宽和高
    // 下面这行代码的完整意思就是我们要从canvas中拿一个图片,
    // 图片从画布的(0,0)位置开始,宽为1000,高600
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    console.log(imgData)
    for (var i = 0; i < imgData.data.length; i += 4) {
        var pxValue = (imgData.data[i] + imgData.data[i + 1] + imgData.data[i + 2]) / 3
        imgData.data[i] = pxValue
        imgData.data[i + 1] = pxValue
        imgData.data[i + 2] = pxValue
    }
    // 重新设置一下canvas画布的对应位置的图片
    // 第二第三个参数是设置的在画布上的起始点的坐标x和y
    ctx.putImageData(imgData, 0, 0)
}

3.黑白效果

黑白效果只有两种颜色,所以色值转换不是255就是0

function handleImg() {
    // 第一第二参数是拿图片数据的起始x和y
    // 第三第四参数是拿到图片的宽和高
    // 下面这行代码的完整意思就是我们要从canvas中拿一个图片,
    // 图片从画布的(0,0)位置开始,宽为1000,高600
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    console.log(imgData)
    for (var i = 0; i < imgData.data.length; i += 4) {
        var pxValue = (imgData.data[i] + imgData.data[i + 1] + imgData.data[i + 2]) / 3 > 128 ? 255 : 0
        imgData.data[i] = pxValue
        imgData.data[i + 1] = pxValue
        imgData.data[i + 2] = pxValue
    }
    // 重新设置一下canvas画布的对应位置的图片
    // 第二第三个参数是设置的在画布上的起始点的坐标x和y
    ctx.putImageData(imgData, 0, 0)
}

4.模糊效果

根据每一个像素点,取自身及周围的的像素点,求出rgb的平均值,然后赋值给对应中心像素点。
我们定义了一个模板变量tmpdata,该变量存储的是图片的原始色值数据,只会被读取,不会被改变。

function handleImg() {
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    // 拿到像素数据
    var pxData = imgData.data
    // 创建和上面一样的imgData的数据对象,当做模板数据
    var tmpdata = ctx.createImageData(canvas.width, canvas.height)
    tmpdata.data.set(pxData)
    // 循环出来画布的每一个像素
    for (var i = 0; i < canvas.width; i++) {
        for (var j = 0; j < canvas.height; j++) {
            // 获取到每个像素的index值
            var op = j * canvas.width + i
            var totalR = 0, totalG = 0, totalB = 0
            // 找到当前像素为中心的周围一共9个像素
            // dx和dy决定了图片的模糊程度
            for (var dx = -2; dx <= 2; dx++) {
                for (var dy = -2; dy <= 2; dy++) {
                    var x = i + dx
                    // 边界处理
                    if (x < 0 || x >= canvas.width) {
                        x = 0
                    }
                    var y = j + dy
                    if (y < 0 || y >= canvas.height) {
                        y = 0
                    }
                    var p = y * canvas.width + x
                    totalR += tmpdata.data[p * 4 + 0]
                    totalG += tmpdata.data[p * 4 + 1]
                    totalB += tmpdata.data[p * 4 + 2]
                }
            }
            var newR = totalR / 25
            var newG = totalG / 25
            var newB = totalB / 25
            pxData[op * 4 + 0] = newR
            pxData[op * 4 + 1] = newG
            pxData[op * 4 + 2] = newB
            pxData[op * 4 + 3] = 255
        }
    }
    ctx.putImageData(imgData, 0, 0)
    console.log(imgData)
}

5.马赛克效果

马赛克效果和模糊效果的区别:马赛克效果是每一个固定区域,只能一次rgb的平均值,然后赋值给这个区域的每一个像素点,最终形成的是一块一块的棱角分明的模糊效果。

function handleImg() {
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    // 拿到像素数据
    var pxData = imgData.data
    // 创建和上面一样的imgData的数据对象,当做模板数据
    var tmpdata = ctx.createImageData(canvas.width, canvas.height)
    tmpdata.data.set(pxData)
    // 设置区块大小
    var size = 4
    var totalSize = size * size
    // 循环出来画布的每一个像素
    for (var i = 0; i < canvas.width; i += 4) {
        for (var j = 0; j < canvas.height; j += 4) {
            // 获取到每个像素的index值
            var op = j * canvas.width + i
            var totalR = 0, totalG = 0, totalB = 0
            // 找到当前像素为中心的周围一共9个像素
            // dx和dy决定了图片的模糊程度
            for (var dx = 0; dx < size; dx++) {
                for (var dy = 0; dy < size; dy++) {
                    var x = i + dx
                    var y = j + dy
                    var p = y * canvas.width + x
                    totalR += tmpdata.data[p * 4 + 0]
                    totalG += tmpdata.data[p * 4 + 1]
                    totalB += tmpdata.data[p * 4 + 2]
                }
            }
            var newR = totalR / totalSize
            var newG = totalG / totalSize
            var newB = totalB / totalSize
            // 通过遍历赋值
            for (var dx = 0; dx < size; dx++) {
                for (var dy = 0; dy < size; dy++) {
                    var x = i + dx
                    var y = j + dy
                    var p = y * canvas.width + x
                    pxData[op * 4 + 0] = newR
                    pxData[p * 4 + 1] = newG
                    pxData[p * 4 + 2] = newB
                }
            }
        }
    }
    ctx.putImageData(imgData, 0, 0)
}

6.毛玻璃效果

毛玻璃效果实现了像素点色值的指定范围内的随机改变,图形会一定程度上产生扭曲的效果。

function handleImg() {
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    // 拿到像素数据
    var pxData = imgData.data
    // 创建和上面一样的imgData的数据对象,当做模板数据
    var tmpdata = ctx.createImageData(canvas.width, canvas.height)
    tmpdata.data.set(pxData)
    for (var i = 0; i < canvas.width; i++) {
        for (var j = 0; j < canvas.height; j++) {
            var op = j * canvas.width + i
            // rand的大小决定了毛玻璃的清晰程度
            var rand = Math.floor(Math.random() * 10)
            var tp = (j + rand) * canvas.width + (i + rand)
            pxData[op * 4] = tmpdata.data[tp * 4]
            pxData[op * 4 + 1] = tmpdata.data[tp * 4 + 1]
            pxData[op * 4 + 2] = tmpdata.data[tp * 4 + 2]
            pxData[op * 4 + 3] = tmpdata.data[tp * 4 + 3]
        }
    }
    ctx.putImageData(imgData, 0, 0)
}

7.浮雕效果

取到每一个像素点和它的后一个像素点,然后两者的rgb色值取差,然后加上128。

function handleImg() {
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    // 拿到像素数据
    var pxData = imgData.data
    // 创建和上面一样的imgData的数据对象,当做模板数据
    var tmpdata = ctx.createImageData(canvas.width, canvas.height)
    tmpdata.data.set(pxData)
    for (var i = 0; i < canvas.width; i++) {
        for (var j = 0; j < canvas.height; j++) {
            var op = j * canvas.width + i
            var tp = j * canvas.width + i + 1
            var r = pxData[op * 4] - pxData[tp * 4] + 128
            var g = pxData[op * 4 + 1] - pxData[tp * 4 + 1] + 128
            var b = pxData[op * 4 + 2] - pxData[tp * 4 + 2] + 128
            var newR = r < 0 ? 0 : (r > 255 ? 255 : r)
            var newG = g < 0 ? 0 : (g > 255 ? 255 : g)
            var newB = b < 0 ? 0 : (b > 255 ? 255 : b)
            pxData[op * 4] = newR
            pxData[op * 4 + 1] = newG
            pxData[op * 4 + 2] = newB
        }
    }
    ctx.putImageData(imgData, 0, 0)
}

8.水平镜像效果

像素点的对称交换,这里实现的是水平位置的交换,两者canvas画布并列,一张实现镜像效果,另外一张不做处理,则可呈现出来水平镜像效果。

function handleImg() {
    var imgData = ctx.getImageData(0, 0, 1000, 600)
    // 拿到像素数据
    var pxData = imgData.data
    // 创建和上面一样的imgData的数据对象,当做模板数据
    var tmpdata = ctx.createImageData(canvas.width, canvas.height)
    tmpdata.data.set(pxData)
    for (var i = 0; i < canvas.width; i++) {
        for (var j = 0; j < canvas.height; j++) {
            var op = j * canvas.width + i
            var tp = j * canvas.width + (canvas.width - 1 - i)
            pxData[op * 4] = tmpdata.data[tp * 4]
            pxData[op * 4 + 1] = tmpdata.data[tp * 4 + 1]
            pxData[op * 4 + 2] = tmpdata.data[tp * 4 + 2]
            pxData[op * 4 + 3] = tmpdata.data[tp * 4 + 3]
        }
    }
    ctx.putImageData(imgData, 0, 0)
}

9.刮刮乐

我们将图片通过css设置成canvas画布的背景,然后在背景图上模拟出刮刮乐效果。
css代码

#canvas {
    background: url('./demo1.webp');
    background-position: center center;
    background-size: cover;
}

html结构代码

<p>
    <button id="btn">开始刮一刮</button>
</p>
<canvas id="canvas" width="1000" height="600"></canvas>

js代码

var btn = document.getElementById("btn")
btn.onclick = generate
var canvas = document.getElementById("canvas")
var ctx = canvas.getContext('2d')
ctx.fillStyle = 'gray'
ctx.fillRect(0, 0, 1000, 600)
function generate() {
    canvas.onmousedown = function (e) {
        canvas.onmousemove = function (e) {
            var x = e.clientX - canvas.offsetLeft
            var y = e.clientY - canvas.offsetTop
            // 清除指定区域的像素
            // 第一第二个参数是清除的起始点x坐标和y坐标
            // 第三第四个参数是清除的区块大小
            //-20/2是为了让清除区域以鼠标为中心
            ctx.clearRect(x-20/2, y-20/2, 20, 20)
        }
        canvas.onmouseup = function (e) {
            canvas.onmousemove = null
        }
    }
}

10.绘制饼图裁剪图片

不需要用css进行设置,html结构代码同“刮刮乐”,js代码部分只有generate事件函数和“刮刮乐”不同。

function generate() {
    ctx.strokeStyle = 'red'
    ctx.moveTo(160, 120)
    // arc方法主要就是为了绘制圆形或者弧线
    // 第一第二个参数是圆心坐标
    // 第三个参数是半径
    // 第四第五个参数是弧度的开始与结束
    ctx.arc(160, 120, 120, 135 * Math.PI / 180, 45 * Math.PI / 180)
    ctx.lineTo(160, 120)
    ctx.stroke()
    // 把绘制好的形状剪切出来
    // 后面绘制的形状和图片都只能显示在剪切出来的这个区域
    ctx.clip()
    var img = new Image()
    img.src = './demo1.webp'
    img.onload = function () {
        ctx.drawImage(img,0,0,400,300)
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,527评论 5 470
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,314评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,535评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,006评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,961评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,220评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,664评论 3 392
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,351评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,481评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,397评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,443评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,123评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,713评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,801评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,010评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,494评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,075评论 2 341